Skip to content

Add annotation provider#1

Open
blazingzephyr wants to merge 4 commits into
sakakibara:mainfrom
blazingzephyr:metadata
Open

Add annotation provider#1
blazingzephyr wants to merge 4 commits into
sakakibara:mainfrom
blazingzephyr:metadata

Conversation

@blazingzephyr

@blazingzephyr blazingzephyr commented Jul 20, 2026

Copy link
Copy Markdown

Added TypeAnnotationProvider and TypeAnnotationOptions for additional json metadata not linked to T.

Reasoning:

  1. Adding metadata to types from other packages.
  2. Support comptime-generated types from @Struct and @Union, where embedding annotations by declaring fields is currently impossible / undesirable behavior.
  3. Allow to overwrite default annotations for specific files when needed.

Usage example:

fn ComponentUnion(comptime container: []const u8, comptime specs: anytype) type {
    var field_names: [specs.len][]const u8 = undefined;
    var field_types: [specs.len]type = undefined;
    var field_attrs: [specs.len]FieldAttributes = undefined;
    ...
    const Tag = ComponentEnum(container, specs);
    return @Union(.auto, Tag, &field_names, &field_types, &field_attrs);
}

const _Query: json.TypeAnnotationProvider(Query) = .{
    .json_tag = "$type",
    .json_rename = &.{ .{ .from = "AlwaysMatchesQuery", .to = "AlwaysMatches" } },
};
pub const Query = ComponentUnion("Queries", .{
    .{
        "AdjacentLaneQuery",
        struct {
            Side: Side,
            OriginEntityType: OriginEntityType,
        },
    },
    .{ "AlwaysMatches", struct {} },
    .{ "AttackComparisonQuery", struct { ComparisonOperator: ComparisonOperator, AttackValue: u8 } },
    ...
});
pub const ComponentUnionRegistry = json.TypeAnnotationOptions(.{ _Query, ... });

blazingzephyr and others added 3 commits July 21, 2026 00:35
Added `TypeAnnotationProvider` and `TypeAnnotationOptions` for json metadata.
@blazingzephyr

Copy link
Copy Markdown
Author

Originally intended to open a second pull request for json_payload, but decided to push it into the metadata branch because this also might fix some of the other issues introduced in my previous commits.

@blazingzephyr

blazingzephyr commented Jul 21, 2026

Copy link
Copy Markdown
Author

Here's how the annotations currently work for my types.

const _CardDescriptor: json.TypeAnnotationProvider(card_descriptor.CardDescriptor) = .{
    .json_rename = &.{
        .{ .to = "se", .from = "special_abilities" },
        .{ .to = "t", .from = "tags" },
    },
    .json_skip = &[_][]const u8{ "_isGravestone", "_isSurprise" },
};
const _EffectEntityComponent: json.TypeAnnotationProvider(EffectEntityComponent) = .{
    .json_tag = "$type",
    .json_payload = "$data",
};
const _Query: json.TypeAnnotationProvider(Query) = .{
    .json_tag = "$type",
    .json_payload = "$data",
    .json_rename = &.{
        .{
            .from = "PvZCards.Engine.Queries.AlwaysMatchesQuery, EngineLib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
            .to = "PvZCards.Engine.Queries.TestRename, EngineLib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
        },
    },
};
const _Component: json.TypeAnnotationProvider(Component) = .{
    .json_tag = "$type",
    .json_payload = "$data",
};
pub const ComponentUnionRegistry = json.TypeAnnotationOptions(.{ _Component, _EffectEntityComponent, _Query, _CardDescriptor });

@sakakibara sakakibara left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together. This is a useful mechanism. Supporting annotations for external and comptime-generated types fills a real gap, and rejecting invalid entries at comptime is the right approach.

There are a few API and correctness details to address before merging. I left inline comments on source compatibility, annotation precedence, and void json_payload encoding.

Please also:

  • Add provider-path tests for json_rename, json_skip, json_flatten, json_tag, json_payload, fromJson, and toJson.
  • Cover provider precedence and sorted and unsorted encode/decode round trips.
  • Add an Unreleased entry to CHANGELOG.md for the new public API.
  • Remove the extra space in the README snippet: cfg, annotations, arena.

Comment thread src/json.zig
/// in use.
pub fn parseInto(comptime T: type, arena: std.mem.Allocator, src: []const u8, options: ParseOptions) (Error || DecodeError)!T {
return decode_mod.parseInto(T, arena, src, options);
pub fn parseInto(comptime T: type, comptime TAnnotation: type, arena: std.mem.Allocator, src: []const u8, options: ParseOptions) (Error || DecodeError)!T {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The annotation registry should not be a new positional parameter. This makes the public typed-codec API source-incompatible even though the feature is opt-in.

Please put it in the existing options structs as a defaulted field, for example annotations: type = DefaultTypes. Existing calls such as parseInto(T, arena, src, .{}) would remain valid, while callers could opt in with .{ .annotations = MyAnnotations }. The lowercase field name also follows the style of dialect and ignore_unknown_fields.

Comment thread src/decode.zig
}

/// Returns true if `field_name` on type `T` is listed in `T.json_skip`
/// or `TAnnotation.json_skip`.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The override behavior is inconsistent here. When a provider exists, json_skip = null suppresses the type-local T.json_skip; json_flatten behaves the same way. In contrast, json_rename = null, or a provider rename list without this field, falls back to T.json_rename.

Please define one precedence rule and apply it consistently across every annotation. My recommendation is that each non-null provider property overrides its type-local counterpart, while null falls back to the type-local declaration. Please cover both override and fallback behavior in tests.

Comment thread src/encoder.zig
try writeQuotedString(w, comptime decode_mod.renamedKey(T, TAnnotation, union_field.name));

if (decode_mod.payloadField(T, TAnnotation)) |payload| {
try writeTypedMember(w, payload, options, depth + 1, &first);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This emits invalid JSON for a void variant: writeTypedMember writes the payload key and colon, but no value is written when union_field.type == void. The sorted path also attempts to render the void value.

Please define the intended representation for a void payload, handle it consistently in sorted and unsorted encoding, and add a round-trip regression test.

@blazingzephyr

Copy link
Copy Markdown
Author

Alright, thank you for feedback! I will look into this shortly.

@blazingzephyr

Copy link
Copy Markdown
Author

Ah, right, I guess I should have mentioned it in the original pull request. The reason why I opted for a function argument instead of an options struct field is because annotations have to be resolved comptime, which breaks compatibility with existing options.

@sakakibara

Copy link
Copy Markdown
Owner

You're right about the comptime constraint. I verified it with a runtime-valued options call: adding a type field makes the entire options struct comptime-only. My earlier options-field suggestion was incorrect; sorry about that.

Please preserve the existing typed-codec entry points and expose custom annotations through a configured codec namespace instead:

const Codec = json.TypedCodec(.{ query_annotations, component_annotations });

const value = try Codec.parseInto(T, arena, src, .{});
try Codec.encode(&writer, value, arena, .{});

TypedCodec(entries) should return a type exposing parseInto, parseIntoReader, decode, and encode; Codec.encode is the configured counterpart of the global encodeTyped, not the dynamic-tree encode. The existing global functions can delegate internally to a private TypedCodec(.{}), so current callers remain source-compatible and runtime-valued ParseOptions and EncodeOptions continue to work.

I also ran the full suite and completed a broader audit. Please treat this as a correction and addendum to my original review.

API naming

  • Use Annotations(T) for one type's external annotation record, AnnotationRename for a rename entry, and TypedCodec(entries) for the configured codec namespace.
  • Use directional field names such as json_name and zig_name instead of from and to.
  • Keep the empty codec private; callers using the existing global functions do not need a public DefaultTypes or default-annotation symbol.

Correctness

  • The current head fails zig build test. Sorted tagged-union encoding instantiates the typed encoder for a void payload, including with an empty annotation set.
  • External annotation hooks currently do not override type-local hooks, despite the encoder documentation saying they take priority. Null fallback is also inconsistent across rename, skip, flatten, tag, payload, and hooks. A non-null external annotation property should override the corresponding type-local property; null should fall back to it.
  • Reject duplicate configured annotations for the same Zig type, ambiguous rename mappings, inapplicable properties, and tag/payload name collisions at comptime. The missing-entry path should also use @typeName(T); it currently produces Zig's expected indexable; found 'type' error instead of the intended diagnostic.
  • For json_payload, decode the nested payload value directly and validate the wrapper object separately. The current code removes a legitimate nested payload field when it has the same name as the discriminator, and it silently accepts unknown fields beside the payload in strict mode. I reproduced both cases with focused tests.
  • I recommend allowing json_payload to wrap any otherwise-supported payload type and omitting it for void variants. Without json_payload, non-void union payloads should be struct-only and fail with a direct comptime diagnostic otherwise. Payload type errors should report the payload value's kind, not the outer object's kind.

Tests and documentation

  • Add focused external-annotation tests for local fallback, external override, duplicate/invalid configurations, hooks, tree/stream equivalence, sorted/unsorted encoding, and struct/scalar/void tagged-union payloads.
  • Correct the README and API table signatures, including the required EncodeOptions argument. Document precedence and payload semantics with a small self-contained example, and include every exported annotation type.
  • Reconcile the source and generated documentation with the implemented contract: hook-priority claims must match the code, json_payload needs a public doc comment, and implementation-rationale narration should be replaced by a concise current API contract.
  • Add the Unreleased changelog entry requested in the original review.
  • When updating the branch, rebase it onto the current main so final verification includes the latest test suite.

I will re-review the revised API and run the full suite again once the branch is updated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants