From 26e540d2fc5b57b468bc6b3bea71584f7d978160 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 21:16:45 +0200 Subject: [PATCH 1/2] fix: raise the comptime branch quota for wide structs Inline loops over a struct's fields count every comptime branch they take, including escaping each key, against the enclosing function's quota. A struct with a few dozen fields ran past the default 1000 and failed to compile. --- CHANGELOG.md | 3 +++ src/decode.zig | 3 +++ src/encode.zig | 16 +++++++++++++ src/test.zig | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c80ad0..82a4257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support for Zig 0.17, alongside 0.16 +### Fixed +- Structs with a few dozen fields failing to compile with "evaluation exceeded 1000 backwards branches" + ## [0.1.0] - 2026-09-16 Initial release. diff --git a/src/decode.zig b/src/decode.zig index cc0c95e..82a86c5 100644 --- a/src/decode.zig +++ b/src/decode.zig @@ -15,6 +15,7 @@ const Allocator = std.mem.Allocator; const json = @import("json.zig"); const compat = @import("compat.zig"); const escapedLiteral = @import("encode.zig").escapedLiteral; +const fieldsBranchQuota = @import("encode.zig").fieldsBranchQuota; pub const DecodeError = error{ /// A byte appeared where the grammar does not allow it. @@ -959,6 +960,7 @@ pub const Decoder = struct { comptime options: json.StructOptions, comptime continuing: bool, ) DecodeError!T { + @setEvalBranchQuota(comptime fieldsBranchQuota(T)); const fields = comptime compat.structFields(T); var result: T = undefined; @@ -1224,6 +1226,7 @@ fn jsonKey(comptime T: type, comptime field_name: []const u8) []const u8 { fn maxKeyLen(comptime T: type) usize { comptime { + @setEvalBranchQuota(fieldsBranchQuota(T)); var m: usize = 1; for (compat.structFields(T)) |f| m = @max(m, jsonKey(T, f.name).len); return m; diff --git a/src/encode.zig b/src/encode.zig index fdb97dd..d707561 100644 --- a/src/encode.zig +++ b/src/encode.zig @@ -151,6 +151,7 @@ fn encodeUnion(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Enco /// Writes a struct's members as `,"name":value`, for an object that already /// has at least one member. fn encodeStructFieldsAfter(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) EncodeError!void { + @setEvalBranchQuota(comptime fieldsBranchQuota(T)); const options = comptime json.structOptions(T); inline for (comptime compat.structFields(T)) |f| { const omit = options.omit_null_fields and @@ -189,6 +190,20 @@ pub fn escapedLiteral(comptime s: []const u8) []const u8 { } } +/// Inline loops over a struct's fields spend comptime branches on every field +/// (escaping its key, among other things), and all of it counts against the +/// enclosing function's quota. A struct with a few dozen fields runs past the +/// default of 1000, so functions looping over fields raise it to this. +pub fn fieldsBranchQuota(comptime T: type) u32 { + comptime { + const fields = compat.structFields(T); + @setEvalBranchQuota(1000 + 10 * fields.len); + var n: u32 = 1000; + for (fields) |f| n += 100 + 10 * @as(u32, @intCast(f.name.len)); + return n; + } +} + fn fieldKey(comptime T: type, comptime field_name: []const u8) []const u8 { const options = comptime json.structOptions(T); return switch (options.key) { @@ -209,6 +224,7 @@ fn canOmitFields(comptime T: type) bool { } fn encodeStruct(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) EncodeError!void { + @setEvalBranchQuota(comptime fieldsBranchQuota(T)); const fields = comptime compat.structFields(T); const options = comptime json.structOptions(T); diff --git a/src/test.zig b/src/test.zig index cc3b510..b24ad52 100644 --- a/src/test.zig +++ b/src/test.zig @@ -302,6 +302,70 @@ test "nested structs, slices and fixed arrays" { try testing.expectEqual(@as(usize, 0), v.empty.len); } +test "a struct with many fields and a jsonFormat" { + // The per-field comptime work used to exhaust the default branch quota. + const Wide = struct { + field_number_0: u8 = 0, + field_number_1: u8 = 0, + field_number_2: u8 = 0, + field_number_3: u8 = 0, + field_number_4: u8 = 0, + field_number_5: u8 = 0, + field_number_6: u8 = 0, + field_number_7: u8 = 0, + field_number_8: u8 = 0, + field_number_9: u8 = 0, + field_number_10: u8 = 0, + field_number_11: u8 = 0, + field_number_12: u8 = 0, + field_number_13: u8 = 0, + field_number_14: u8 = 0, + field_number_15: u8 = 0, + field_number_16: u8 = 0, + field_number_17: u8 = 0, + field_number_18: u8 = 0, + field_number_19: u8 = 0, + field_number_20: u8 = 0, + field_number_21: u8 = 0, + field_number_22: u8 = 0, + field_number_23: u8 = 0, + field_number_24: u8 = 0, + field_number_25: u8 = 0, + field_number_26: u8 = 0, + field_number_27: u8 = 0, + field_number_28: u8 = 0, + field_number_29: u8 = 0, + field_number_30: u8 = 0, + field_number_31: u8 = 0, + field_number_32: u8 = 0, + field_number_33: u8 = 0, + field_number_34: u8 = 0, + field_number_35: u8 = 0, + field_number_36: u8 = 0, + field_number_37: u8 = 0, + field_number_38: u8 = 0, + field_number_39: u8 = 0, + field_number_40: u8 = 0, + field_number_41: u8 = 0, + field_number_42: u8 = 0, + field_number_43: u8 = 0, + field_number_44: u8 = 0, + field_number_45: u8 = 0, + field_number_46: u8 = 0, + field_number_47: u8 = 0, + + pub fn jsonFormat() json.StructOptions { + return .{ .omit_null_fields = false }; + } + }; + + var buf: [1024]u8 = undefined; + const doc = try encodeToBuf(Wide{ .field_number_47 = 9 }, &buf); + const v = try json.decodeFromSlice(Wide, std.testing.allocator, doc, .{}); + defer v.deinit(); + try testing.expectEqual(@as(u8, 9), v.value.field_number_47); +} + test "enums and tagged unions" { const Color = enum { red, green, blue }; const Shape = union(enum) { circle: f64, rect: [2]u32, none }; From 9d1777d34584023415242255699b5cbeb8e4a057 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 21:16:45 +0200 Subject: [PATCH 2/2] feat: decode single-item pointers A *T is allocated and decoded in place, which is what recursive types like a status that embeds another status need. Encoding already dereferenced them. --- CHANGELOG.md | 1 + src/decode.zig | 16 ++++++++++++---- src/test.zig | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a4257..fa4c349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support for Zig 0.17, alongside 0.16 +- Decoding single-item pointers (`*T`, `?*T`), which makes recursive types work ### Fixed - Structs with a few dozen fields failing to compile with "evaluation exceeded 1000 backwards branches" diff --git a/src/decode.zig b/src/decode.zig index 82a86c5..bff9c73 100644 --- a/src/decode.zig +++ b/src/decode.zig @@ -420,10 +420,18 @@ pub const Decoder = struct { } return d.fixedArray(T, arr.child, arr.len); }, - .pointer => |ptr| { - if (ptr.size != .slice) @compileError("json: cannot decode " ++ @typeName(T)); - if (ptr.child == u8) return d.string(); - return d.slice(T, ptr.child); + .pointer => |ptr| switch (ptr.size) { + .slice => { + if (ptr.child == u8) return d.string(); + return d.slice(T, ptr.child); + }, + .one => { + const p = try d.gpa.create(ptr.child); + errdefer d.gpa.destroy(p); + p.* = try d.value(ptr.child); + return p; + }, + else => @compileError("json: cannot decode " ++ @typeName(T)), }, else => @compileError("json: cannot decode " ++ @typeName(T)), } diff --git a/src/test.zig b/src/test.zig index b24ad52..2128b76 100644 --- a/src/test.zig +++ b/src/test.zig @@ -302,6 +302,29 @@ test "nested structs, slices and fixed arrays" { try testing.expectEqual(@as(usize, 0), v.empty.len); } +test "single-item pointers, including recursive ones" { + const Node = struct { + value: u32, + next: ?*@This(), + + pub fn jsonFormat() json.StructOptions { + return .{ .omit_null_fields = false }; + } + }; + const doc = "{\"value\":1,\"next\":{\"value\":2,\"next\":null}}"; + + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const v = try json.decodeFromSliceLeaky(Node, arena.allocator(), doc, .{}); + try testing.expectEqual(@as(u32, 1), v.value); + try testing.expectEqual(@as(u32, 2), v.next.?.value); + try testing.expectEqual(@as(?*Node, null), v.next.?.next); + try expectEncodes(v, doc); + + const p = try json.decodeFromSliceLeaky(*const u32, arena.allocator(), "7", .{}); + try testing.expectEqual(@as(u32, 7), p.*); +} + test "a struct with many fields and a jsonFormat" { // The per-field comptime work used to exhaust the default branch quota. const Wide = struct {