Skip to content

Add annotation provider - #1

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

blazingzephyr wants to merge 7 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 Outdated
/// 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 Outdated
}

/// 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 Outdated
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.

@blazingzephyr

Copy link
Copy Markdown
Author

Hello! Just wanted to report that I've just recently come back to working on this and the progress has since been steady, currently finishing the tests and then only need to adjust documentation.

blazingzephyr and others added 3 commits July 29, 2026 00:01
* feat: create missing intermediate objects in Document.set, add empty-doc bootstrap and segment paths

Document.set now creates missing intermediate objects along a path
(e.g. set("a.b.c", v) creates a and a.b as needed) instead of returning
error.PathNotFound; array elements are still only ever replaced, never
created. Adds Document.empty for bootstrapping a document with no
source bytes, and setValueSegments/setSegments/removeSegments for
addressing a path as pre-split key segments so a key containing a
literal "." is unambiguous. set/setValue/remove now route through the
same segment-based core internally, splitting string paths the same
way as before.

* chore(release): prepare 0.3.0

* test(document): add deterministic property/round-trip invariant battery

* test(document): make invariant 7's comment exclusion branch-aware

The remove-comment exclusion previously took the union of both
non-last/last swept-trivia sets, which over-excluded and let a real
comment-loss bug (dropping the removed member's own leading comment
on a non-last removal) slip past the battery. Match Document.removeSeg's
three object branches exactly instead. Also switch the comment
presence checks (invariants 5 and 7) from whole-output substring
search to delimited-token multiset matching, so one comment's text
can't be masked by another's that is a prefix of it, or by a
colliding bare key.

* ci: move CI and the release action off Node 20

softprops/action-gh-release v3.0.0 moves the release action from Node 20 to
Node 24 and nothing else; repin from v2.6.2 to v3.0.2 by its SHA. Bump
actions/checkout in ci.yml from v4 (Node 20) to v6 (Node 24) to match. Node
20 is being retired for GitHub Actions.

* style: run zig fmt over the source tree

Manual call wrapping, hand-aligned trailing comments, and compressed
single-line blocks; zig fmt canonicalizes all of it. No semantic
change.

* ci: add format, release-build, fuzz, and bench gates

The fuzzer (fuzz/main.zig) existed but CI never ran it; run 200k
deterministic iterations on every push. Add zig fmt --check to the test
matrix, with .gitattributes pinning LF checkout so the gate works on
Windows (Git for Windows defaults core.autocrlf on and zig fmt rejects
CRLF). Also build in ReleaseFast, run the microbenchmarks on pull
requests and manual dispatch, cache the zig build directory, cancel
superseded runs, allow manual dispatch, and bound every job at 20
minutes.

* feat(bench): print zig, target, and cpu provenance in the header

Published numbers need to say what they were measured on; the harness
now prints zig version, optimize mode, target triple, and CPU model
before the table.

* docs: measure the benchmark table natively with a stated protocol

The previous numbers were captured inside a Linux VM (labeled
aarch64-linux) on the same machine, which hid the real hardware.
Re-measure natively: Apple M1 Max, Zig 0.16.0, ReleaseFast, p50 per
harness run, median of ten runs, worst cross-run spread below 8% per
cell, and say all of that next to the table.

* ci: run bench on every push and format-check bench and fuzz

Run the bench job on push as a build-and-runs gate instead of only on
pull requests and manual dispatch, and widen the format check to
bench/ and fuzz/, which were outside the gate.

---------

Co-authored-by: Sho Sakakibara <sho@sakakibara.dev>
@blazingzephyr
blazingzephyr requested a review from sakakibara July 28, 2026 23:38

@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.

Thank you for the revision. TypedCodec / Annotations(T) is the right shape, the global entry points are source-compatible again, and three things from last round are fixed: void json_payload encoding, a nested payload field colliding with the discriminator, and strict-mode unknown fields beside the payload. The changelog entry is there too.

At bf08e4a all gates pass, and a differential corpus against main shows no runtime change for callers not using a codec.

I have audited this fairly closely and found a number of things, but most of them are mine to fix rather than yours, so the list below is short on purpose.

What I would like from you

  1. Fix sort_keys with json_payload (src/encoder.zig:300). It emits two concatenated objects today, which is invalid JSON. There is a committable suggestion on the inline comment, which I applied and verified against the full suite. Please add a regression test alongside it, since that branch has no coverage.
  2. Decide the precedence API (src/codec.zig:60). I would like precedence moved into TypedCodec construction with the per-call parameter dropped, and four modes reduced to two. This is the only item that becomes published API, so it is the only one that gets meaningfully more expensive after merge. Reasoning is on the inline comment.
  3. Add tests for Codec.decode and Codec.parseIntoReader, which have no test uses at all, and extend the tree-versus-stream test to cover a codec-supplied rename rather than only json_skip.
  4. Rebase onto current main. The conflicts in CHANGELOG.md and src/decode.zig are mechanical, caused by the upstream commits arriving as a squash rather than a merge.

Once those are in I intend to merge.

What I will handle myself after merge

Several of these are things I asked you for in earlier rounds, including the comptime validations and the precedence rule. I am taking them back. They are small, they sit in code I know well, and none of them touch published API, so none get more expensive by landing after merge. Recorded here so none of it is a surprise later, but please do not spend time on any of it.

  • json.Value union payloads (src/decode.zig:624). Last round I asked for non-void payloads to be struct-only without json_payload, and you implemented that correctly. I had not considered json.Value, which decodes on main and no longer compiles. My instruction was too strict, so the fix is mine.
  • fromJson hooks returning T (src/decode.zig:332). Taking the hook as a function pointer stops a hook shape that compiles on main from compiling. The lost arity checks on fromJson and toJson belong with it.
  • The getJsonTag diagnostic (src/decode.zig:219). orelse unreachable replaced a compile error that named the type. Diagnostic quality only.
  • json_skip and json_flatten resolving by union rather than override (src/decode.zig:152), which makes two of the four modes identical for those properties.
  • The comptime validation gaps (src/codec.zig:105 and :145): ambiguous renames, inapplicable properties, the missing-entry diagnostic, and type-local or cross-source json_tag / json_payload collisions.
  • The json_payload diagnostics (src/decode.zig:680): error precedence, the missing suggestion, and the dotted path.
  • The README and API tables, the source documentation, the leftover src/annotation.zig, and the example. I asked for the documentation work in an earlier round and I am withdrawing that request.

Happy to re-review as soon as the four above are done.

Comment thread src/encoder.zig
Comment on lines +300 to +307
const names = [_][]const u8{json_payload};
const types = [_]type{union_field.type};
const atts = [_]std.builtin.Type.StructField.Attributes{.{}};

const Payload = @Struct(.auto, null, &names, &types, &atts);
var payload: Payload = undefined;
@field(payload, json_payload) = @field(value, union_field.name);
try writeTypedValue(Payload, TCodec, codec_priority, payload, w, arena, options, depth + 1);

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. The payload goes straight to w while every other member is buffered into members and emitted afterwards, so it lands before the discriminator:

const C = union(enum) {
    pub const json_tag = "kind";
    pub const json_payload = "value";
    version: struct { major: u8, minor: u8 },
    none: void,
};
try json.encodeTyped(&aw.writer, C{ .version = .{ .major = 1, .minor = 0 } }, a, .{ .sort_keys = true });
// {"value":{"major":1,"minor":0}}{"kind":"version"}

This reproduces through plain json.encodeTyped with type-local annotations, and the output fails this library's own parser. Every non-void payload shape is affected; a null optional payload additionally collapses to {}{"kind":"maybe"}, and indent comes out a level off.

Appending the rendered payload as a member fixes all of it, and drops the need for the synthesized @Struct:

Suggested change
const names = [_][]const u8{json_payload};
const types = [_]type{union_field.type};
const atts = [_]std.builtin.Type.StructField.Attributes{.{}};
const Payload = @Struct(.auto, null, &names, &types, &atts);
var payload: Payload = undefined;
@field(payload, json_payload) = @field(value, union_field.name);
try writeTypedValue(Payload, TCodec, codec_priority, payload, w, arena, options, depth + 1);
try members.append(arena, .{
.key = json_payload,
.json = try renderTypedValue(union_field.type, TCodec, codec_priority, @field(value, union_field.name), arena, options, depth + 1),
});

I applied exactly that and ran it: zig build test and zig fmt --check both pass, and sorted output becomes {"kind":"version","value":{"major":1,"minor":0}}, {"kind":"maybe","value":null}, and {"kind":"none"}, all of which re-decode.

This got through because the one sort_keys union test at src/encoder.zig:1320 uses a union without json_payload, leaving this branch uncovered. Please add a sorted-encoding test for json_payload on both the type-local and codec paths.

This is the same sorted-path gap I raised in the first round, when I asked for the void payload to be handled consistently in sorted and unsorted encoding. Void is fixed; the non-void sorted path still needs it. Everything else I found is on the review body, and it is all mine to fix.

Comment thread src/codec.zig
}

/// Defines precedence rules for annotations.
pub const AnnotationsSource = enum {

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 is the one design decision I would like settled before merge, since it is the only part that becomes published API.

Proposal: move precedence into TypedCodec construction, drop the per-call parameter, and reduce these four modes to two.

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

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

Two reasons are defects rather than taste.

Nothing binds decode and encode to the same mode. Your Euclidean tests differ only in name, mode, and expectation, giving 503 under .codec_then_local against 53 under .local_then_codec, so encoding with one and decoding with the other turns 5, 3 into 50, 3 with no error.

And the mode applies to the whole type graph rather than the annotated root, so .codec_only silently discards a nested type's annotations, including json_skip:

const Inner = struct {
    pub const json_rename = .{ .listen_addr = "listen-addr" };
    pub const json_skip = .{"secret"};
    listen_addr: []const u8,
    secret: u8 = 7,
};
// Outer is in the codec, Inner is not
// .codec_then_local => {"n":"x","inner":{"listen-addr":"0.0.0.0"}}
// .codec_only       => {"n":"x","inner":{"listen_addr":"0.0.0.0","secret":7}}

A field the type asked to omit gets emitted. Binding the mode to the codec narrows the round-trip hazard without removing it, since one codec with toJson set and fromJson null is still asymmetric, but the asymmetry then has to be written into an entry rather than arising from two call sites drifting apart.

On which two to keep:

  • Drop .local_only. TypedCodec(.{}) under either mixed mode is byte-identical to it across all seven properties, including nested and recursive types, and the global json.parseInto / encodeTyped already serve the ignore-the-codec case.
  • Consider dropping .local_then_codec. For json_skip and json_flatten the two mixed modes are the same function, so nothing is lost. For json_tag, json_payload, fromJson, and toJson, leaving the codec field null demotes it. json_rename needs the most care: you would omit every tuple whose zig_name also has a local rename.
  • Keep .codec_only. For json_skip, json_flatten, json_payload, and the two hooks it is the only way to suppress a type's own annotation, since skip and flatten have no un-set and json_payload cannot express "none". It is not needed for json_rename, where an identity tuple under .codec_then_local gives identical bytes, nor for json_tag, which a codec tag simply overrides.

One related gap to decide on, since it bears on the external-type use case: json_skip requires the skipped field to have a default, which you cannot add to a type you do not own. const Foreign = struct { name: []const u8, internal: u32 }; with a codec json_skip for internal fails with json_skip field 'internal' on Foreign has no default value. Assigning undefined, or documenting the limit, would both be reasonable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Interesting, I will look into this, thanks!

This branch has not been deployed

No deployments
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