Repository navigation
Add annotation provider - #1
blazingzephyr wants to merge 7 commits into
Conversation
Added `TypeAnnotationProvider` and `TypeAnnotationOptions` for json metadata.
|
Originally intended to open a second pull request for |
|
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
left a comment
There was a problem hiding this comment.
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, andtoJson. - Cover provider precedence and sorted and unsorted encode/decode round trips.
- Add an
Unreleasedentry toCHANGELOG.mdfor the new public API. - Remove the extra space in the README snippet:
cfg, annotations, arena.
| /// 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 { |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| /// Returns true if `field_name` on type `T` is listed in `T.json_skip` | ||
| /// or `TAnnotation.json_skip`. |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
|
Alright, thank you for feedback! I will look into this shortly. |
|
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. |
|
You're right about the comptime constraint. I verified it with a runtime-valued options call: adding a 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, .{});
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
Correctness
Tests and documentation
I will re-review the revised API and run the full suite again once the branch is updated. |
|
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. |
* 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>
There was a problem hiding this comment.
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
- Fix
sort_keyswithjson_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. - Decide the precedence API (
src/codec.zig:60). I would like precedence moved intoTypedCodecconstruction 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. - Add tests for
Codec.decodeandCodec.parseIntoReader, which have no test uses at all, and extend the tree-versus-stream test to cover a codec-supplied rename rather than onlyjson_skip. - Rebase onto current
main. The conflicts inCHANGELOG.mdandsrc/decode.zigare 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.Valueunion payloads (src/decode.zig:624). Last round I asked for non-void payloads to be struct-only withoutjson_payload, and you implemented that correctly. I had not consideredjson.Value, which decodes onmainand no longer compiles. My instruction was too strict, so the fix is mine.fromJsonhooks returningT(src/decode.zig:332). Taking the hook as a function pointer stops a hook shape that compiles onmainfrom compiling. The lost arity checks onfromJsonandtoJsonbelong with it.- The
getJsonTagdiagnostic (src/decode.zig:219).orelse unreachablereplaced a compile error that named the type. Diagnostic quality only. json_skipandjson_flattenresolving 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:105and:145): ambiguous renames, inapplicable properties, the missing-entry diagnostic, and type-local or cross-sourcejson_tag/json_payloadcollisions. - The
json_payloaddiagnostics (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.
| 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); |
There was a problem hiding this comment.
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:
| 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.
| } | ||
|
|
||
| /// Defines precedence rules for annotations. | ||
| pub const AnnotationsSource = enum { |
There was a problem hiding this comment.
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 globaljson.parseInto/encodeTypedalready serve the ignore-the-codec case. - Consider dropping
.local_then_codec. Forjson_skipandjson_flattenthe two mixed modes are the same function, so nothing is lost. Forjson_tag,json_payload,fromJson, andtoJson, leaving the codec field null demotes it.json_renameneeds the most care: you would omit every tuple whosezig_namealso has a local rename. - Keep
.codec_only. Forjson_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 andjson_payloadcannot express "none". It is not needed forjson_rename, where an identity tuple under.codec_then_localgives identical bytes, nor forjson_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.
There was a problem hiding this comment.
Interesting, I will look into this, thanks!
Added
TypeAnnotationProviderandTypeAnnotationOptionsfor additional json metadata not linked toT.Reasoning:
@Structand@Union, where embedding annotations by declaring fields is currently impossible / undesirable behavior.Usage example: