From bef46c7249c3b266a4e7f46d591f2fed22e243b1 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 21:18:56 +0200 Subject: [PATCH 1/2] feat: decode fixed-size arrays packAny already wrote [N]T, as an array of N items, or as a string for [N]u8, but unpackAny had no case for it. Decode both forms, and reject a length other than N. --- CHANGELOG.md | 1 + src/any.zig | 11 +++++++++++ src/array.zig | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3daab89..8dffc38 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 fixed-size arrays (`[N]T`), which could already be encoded ### Changed - Encoding writes straight into the writer's buffer when there is room, and keeps the drain path out of line, which is about 13% cheaper on a typical struct and 37% on an array of integers diff --git a/src/any.zig b/src/any.zig index 4220b0a..d47daff 100644 --- a/src/any.zig +++ b/src/any.zig @@ -21,6 +21,7 @@ const Binary = @import("binary.zig").Binary; const packArray = @import("array.zig").packArray; const unpackArray = @import("array.zig").unpackArray; +const unpackFixedArray = @import("array.zig").unpackFixedArray; const packStruct = @import("struct.zig").packStruct; const unpackStruct = @import("struct.zig").unpackStruct; @@ -110,6 +111,7 @@ pub fn unpackAny(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime .@"struct" => return unpackStruct(reader, allocator, T), .@"union" => return unpackUnion(reader, allocator, T), .@"enum" => return unpackEnum(reader, T), + .array => return unpackFixedArray(reader, allocator, T), .pointer => |ptr_info| { if (ptr_info.size == .slice) { if (isString(T)) { @@ -325,6 +327,15 @@ test "packAny/unpackAny: optional union" { } } +test "packAny/unpackAny: fixed array" { + var buffer: [16]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + try packAny(&writer, [2]u16{ 300, 7 }); + + var reader = std.Io.Reader.fixed(writer.buffered()); + try std.testing.expectEqual([2]u16{ 300, 7 }, try unpackAny(&reader, std.testing.allocator, [2]u16)); +} + test "packAny/unpackAny: String struct" { var buffer: [64]u8 = undefined; var writer = std.Io.Writer.fixed(&buffer); diff --git a/src/array.zig b/src/array.zig index 58b7bb0..04c3a02 100644 --- a/src/array.zig +++ b/src/array.zig @@ -9,6 +9,8 @@ const maybePackNull = @import("null.zig").maybePackNull; const maybeUnpackNull = @import("null.zig").maybeUnpackNull; const packHeaderAndInt = @import("utils.zig").packHeaderAndInt; +const readSliceFast = @import("utils.zig").readSliceFast; +const unpackStringHeader = @import("string.zig").unpackStringHeader; const unpackIntValue = @import("int.zig").unpackIntValue; const packAny = @import("any.zig").packAny; @@ -83,6 +85,29 @@ pub fn unpackArray(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptim return data; } +/// Unpacks a fixed-size array, the counterpart of how `packAny` writes one: an +/// array of exactly `N` items, or for `[N]u8` a string of exactly `N` bytes. +pub fn unpackFixedArray(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime T: type) !T { + const info = @typeInfo(NonOptional(T)).array; + const unpackHeader = if (info.child == u8) unpackStringHeader else unpackArrayHeader; + + const len = if (isOptional(T)) + try unpackHeader(reader, ?u32) orelse return null + else + try unpackHeader(reader, u32); + if (len != info.len) return error.InvalidFormat; + + var result: NonOptional(T) = undefined; + if (info.child == u8) { + try readSliceFast(reader, &result); + } else { + for (&result) |*item| { + item.* = try unpackAny(reader, allocator, info.child); + } + } + return result; +} + pub fn unpackArrayInto(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime Item: type, buffer: []Item) ![]Item { const len = try unpackArrayHeader(reader, u32); @@ -131,6 +156,35 @@ test "packArray: null" { try std.testing.expectEqualSlices(u8, &packed_null, writer.buffered()); } +test "unpackFixedArray: round trip" { + var buffer: [32]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + try packAny(&writer, [2]f64{ 1.5, -2.25 }); + + var reader = std.Io.Reader.fixed(writer.buffered()); + try std.testing.expectEqual([2]f64{ 1.5, -2.25 }, try unpackFixedArray(&reader, std.testing.allocator, [2]f64)); +} + +test "unpackFixedArray: [N]u8 is a string, like packAny writes it" { + var buffer: [16]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + try packAny(&writer, [3]u8{ 'a', 'b', 'c' }); + + var reader = std.Io.Reader.fixed(writer.buffered()); + try std.testing.expectEqual([3]u8{ 'a', 'b', 'c' }, try unpackFixedArray(&reader, std.testing.allocator, [3]u8)); +} + +test "unpackFixedArray: null" { + var reader = std.Io.Reader.fixed(&packed_null); + try std.testing.expectEqual(null, try unpackFixedArray(&reader, std.testing.allocator, ?[3]u32)); +} + +test "unpackFixedArray: wrong length" { + const packed_two = [_]u8{ 0x92, 0x01, 0x02 }; + var reader = std.Io.Reader.fixed(&packed_two); + try std.testing.expectError(error.InvalidFormat, unpackFixedArray(&reader, std.testing.allocator, [3]u32)); +} + test "sizeOfPackedArrayHeader: array16 boundary" { try std.testing.expectEqual(3, sizeOfPackedArrayHeader(16)); } From 9e54aa269807cbd0b8959def6af7b66b6b9ecb27 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 21:18:56 +0200 Subject: [PATCH 2/2] feat: decode single-item pointers, including recursive types A *T is allocated and unpacked in place. A struct that holds an optional pointer to its own type made the inferred error sets of packAny and unpackAny depend on themselves, which Zig rejects as a dependency loop. Recursion through a pointer to a struct or union now goes through a function returning anyerror, which breaks the loop at the cost of a wider error set for such types. --- CHANGELOG.md | 1 + src/any.zig | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dffc38..07f93fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,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 fixed-size arrays (`[N]T`), which could already be encoded +- Decoding single-item pointers (`*T`, `?*T`), including recursive types; a type holding a pointer to a struct or union decodes and encodes with `anyerror` ### Changed - Encoding writes straight into the writer's buffer when there is room, and keeps the drain path out of line, which is about 13% cheaper on a typical struct and 37% on an array of integers diff --git a/src/any.zig b/src/any.zig index d47daff..556b915 100644 --- a/src/any.zig +++ b/src/any.zig @@ -23,6 +23,9 @@ const packArray = @import("array.zig").packArray; const unpackArray = @import("array.zig").unpackArray; const unpackFixedArray = @import("array.zig").unpackFixedArray; +const unpackNullIfPresent = @import("null.zig").unpackNullIfPresent; +const isOptional = @import("utils.zig").isOptional; + const packStruct = @import("struct.zig").packStruct; const unpackStruct = @import("struct.zig").unpackStruct; @@ -79,6 +82,7 @@ pub fn packAny(writer: *std.Io.Writer, value: anytype) !void { }, } } else if (ptr_info.size == .one) { + if (comptime isContainer(ptr_info.child)) return packPointee(writer, value); return packAny(writer, value.*); } }, @@ -119,6 +123,9 @@ pub fn unpackAny(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime } else { return unpackArray(reader, allocator, T); } + } else if (ptr_info.size == .one) { + if (comptime isContainer(ptr_info.child)) return unpackPointerToContainer(reader, allocator, T); + return unpackPointer(reader, allocator, T); } }, else => {}, @@ -126,6 +133,37 @@ pub fn unpackAny(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime @compileError("Unsupported type '" ++ @typeName(T) ++ "'"); } +/// A struct or union reached through a pointer may be recursive, holding a +/// pointer to its own type. Its inferred error set would then depend on itself, +/// which Zig rejects as a dependency loop, so the recursion through such a +/// pointer goes through a function with an explicit error set. +fn isContainer(comptime T: type) bool { + return switch (@typeInfo(T)) { + .@"struct", .@"union" => true, + else => false, + }; +} + +fn packPointee(writer: *std.Io.Writer, value: anytype) anyerror!void { + return packAny(writer, value.*); +} + +fn unpackPointerToContainer(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime T: type) anyerror!T { + return unpackPointer(reader, allocator, T); +} + +/// Allocates the pointee and unpacks into it. This is what recursive types +/// need, such as a node that holds an optional pointer to another node. +fn unpackPointer(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime T: type) !T { + if (isOptional(T) and try unpackNullIfPresent(reader)) return null; + + const Child = @typeInfo(NonOptional(T)).pointer.child; + const ptr = try allocator.create(Child); + errdefer allocator.destroy(ptr); + ptr.* = try unpackAny(reader, allocator, Child); + return ptr; +} + test "packAny/unpackAny: bool" { var buffer: [16]u8 = undefined; var writer = std.Io.Writer.fixed(&buffer); @@ -336,6 +374,37 @@ test "packAny/unpackAny: fixed array" { try std.testing.expectEqual([2]u16{ 300, 7 }, try unpackAny(&reader, std.testing.allocator, [2]u16)); } +test "packAny/unpackAny: pointer" { + var buffer: [16]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + const value: u32 = 1234; + try packAny(&writer, &value); + + var reader = std.Io.Reader.fixed(writer.buffered()); + const ptr = try unpackAny(&reader, std.testing.allocator, *const u32); + defer std.testing.allocator.destroy(ptr); + try std.testing.expectEqual(1234, ptr.*); +} + +test "packAny/unpackAny: recursive struct through an optional pointer" { + const Node = struct { + value: u32, + next: ?*@This() = null, + }; + var buffer: [32]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + var tail: Node = .{ .value = 2 }; + try packAny(&writer, Node{ .value = 1, .next = &tail }); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var reader = std.Io.Reader.fixed(writer.buffered()); + const head = try unpackAny(&reader, arena.allocator(), Node); + try std.testing.expectEqual(1, head.value); + try std.testing.expectEqual(2, head.next.?.value); + try std.testing.expectEqual(null, head.next.?.next); +} + test "packAny/unpackAny: String struct" { var buffer: [64]u8 = undefined; var writer = std.Io.Writer.fixed(&buffer);