From 2fc238ed01bca7c3bc46a8a728ac35356fb4b4a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Jul 2026 14:34:46 -0700 Subject: [PATCH] terminal/snapshot: decode directly into pages --- src/terminal/hyperlink.zig | 4 +- src/terminal/snapshot/hyperlink.zig | 297 +++++++------- src/terminal/snapshot/page.zig | 574 +++++++++++++++------------- 3 files changed, 462 insertions(+), 413 deletions(-) diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index 35a16a2ae..68f914f89 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -194,12 +194,12 @@ pub const PageEntry = struct { const alloc = &page.string_alloc; switch (self.id) { .implicit => {}, - .explicit => |v| alloc.free( + .explicit => |v| if (v.len > 0) alloc.free( page.memory, v.slice(page.memory), ), } - alloc.free( + if (self.uri.len > 0) alloc.free( page.memory, self.uri.slice(page.memory), ); diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 966bed247..01e36702c 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -34,86 +34,43 @@ //! Both variants have nine bytes of fixed overhead. The remaining bytes are //! the URI plus the explicit ID, when present. //! -//! Decoding never allocates. The caller provides storage for the explicit ID -//! and URI bytes, and the returned hyperlink borrows slices of that storage. +//! The standalone decoder returns an allocator-owned hyperlink. PAGE decoding +//! reads strings directly into the destination page's allocator instead. const std = @import("std"); +const Allocator = std.mem.Allocator; const io = @import("io.zig"); const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_page = @import("../page.zig"); +const terminal_size = @import("../size.zig"); const Kind = enum(u8) { implicit = 1, explicit = 2, }; -/// Errors possible while determining encoded string lengths. -pub const StringBytesError = error{ - /// A string cannot fit its `u32` length or local size arithmetic overflowed. - StringTooLong, -}; - /// Errors possible while encoding one hyperlink entry. -pub const EncodeError = std.Io.Writer.Error || StringBytesError; +pub const EncodeError = std.Io.Writer.Error; -/// Errors possible while decoding one hyperlink entry. -pub const DecodeError = std.Io.Reader.Error || error{ +/// Errors possible while decoding one allocator-owned hyperlink entry. +pub const DecodeError = std.Io.Reader.Error || Allocator.Error || error{ /// The hyperlink kind is not defined by snapshot version 0. InvalidKind, - - /// Caller-provided string storage cannot hold the decoded entry. - BufferTooSmall, }; -/// One decoded hyperlink and the number of caller-owned string bytes it uses. -pub const Decoded = struct { - value: terminal_hyperlink.Hyperlink, - string_bytes: usize, -}; - -/// Return the raw explicit-ID and URI bytes encoded by `value`. -/// -/// Length prefixes and the numeric implicit ID are not included. -pub fn stringBytes( - value: terminal_hyperlink.Hyperlink, -) StringBytesError!usize { - _ = std.math.cast(u32, value.uri.len) orelse { - return error.StringTooLong; +/// Errors possible while decoding directly into a native page. +pub const DecodePageError = std.Io.Reader.Error || + terminal_page.Page.InsertHyperlinkError || + error{ + /// The hyperlink kind is not defined by snapshot version 0. + InvalidKind, }; - var result = value.uri.len; - - switch (value.id) { - .implicit => {}, - .explicit => |id| { - _ = std.math.cast(u32, id.len) orelse { - return error.StringTooLong; - }; - result = std.math.add(usize, result, id.len) catch { - return error.StringTooLong; - }; - }, - } - - return result; -} - -/// Return the complete encoded length of one hyperlink entry. -pub fn encodedLen( - value: terminal_hyperlink.Hyperlink, -) StringBytesError!usize { - const string_bytes = try stringBytes(value); - return std.math.add(usize, 9, string_bytes) catch { - return error.StringTooLong; - }; -} /// Encode one hyperlink entry. pub fn encode( value: terminal_hyperlink.Hyperlink, writer: *std.Io.Writer, ) EncodeError!void { - // Validate every length before writing any part of the entry. - _ = try stringBytes(value); - switch (value.id) { .implicit => |id| { try writer.writeByte(@intFromEnum(Kind.implicit)); @@ -131,67 +88,139 @@ pub fn encode( } } -/// Decode one hyperlink entry into caller-owned string storage. +/// Decode one allocator-owned hyperlink entry. /// -/// The returned hyperlink remains valid only as long as `strings` remains -/// valid and unmodified. `string_bytes` identifies the prefix of `strings` -/// used by this entry. +/// The caller owns the returned value and must call `Hyperlink.deinit`. pub fn decode( reader: *std.Io.Reader, - strings: []u8, -) DecodeError!Decoded { + alloc: Allocator, +) DecodeError!terminal_hyperlink.Hyperlink { const kind_raw = try reader.takeByte(); - const kind = std.enums.fromInt(Kind, kind_raw) orelse { + const kind = std.enums.fromInt(Kind, kind_raw) orelse return error.InvalidKind; - }; return switch (kind) { - .implicit => decodeImplicit(reader, strings), - .explicit => decodeExplicit(reader, strings), + .implicit => implicit: { + const id = try io.readInt(reader, u32); + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + const uri = try alloc.alloc(u8, uri_len); + errdefer alloc.free(uri); + try reader.readSliceAll(uri); + + break :implicit .{ + .id = .{ .implicit = id }, + .uri = uri, + }; + }, + + .explicit => explicit: { + const id_len: usize = @intCast(try io.readInt(reader, u32)); + const id = try alloc.alloc(u8, id_len); + errdefer alloc.free(id); + try reader.readSliceAll(id); + + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + const uri = try alloc.alloc(u8, uri_len); + errdefer alloc.free(uri); + try reader.readSliceAll(uri); + + break :explicit .{ + .id = .{ .explicit = id }, + .uri = uri, + }; + }, }; } -fn decodeImplicit( +/// Decode one hyperlink directly into page-owned storage. +/// +/// Explicit ID and URI bytes are read into the page string allocator and the +/// completed entry is inserted into the page hyperlink set. The returned ID is +/// the native page ID assigned to the entry. +pub fn decodePage( + page: *terminal_page.Page, reader: *std.Io.Reader, - strings: []u8, -) DecodeError!Decoded { - const id = try io.readInt(reader, u32); - const uri_len: usize = @intCast(try io.readInt(reader, u32)); - if (uri_len > strings.len) return error.BufferTooSmall; +) DecodePageError!terminal_hyperlink.Id { + const kind_raw = try reader.takeByte(); + const kind = std.enums.fromInt(Kind, kind_raw) orelse + return error.InvalidKind; - const uri = strings[0..uri_len]; - try reader.readSliceAll(uri); - return .{ - .value = .{ - .id = .{ .implicit = id }, - .uri = uri, + const entry: terminal_hyperlink.PageEntry = switch (kind) { + .implicit => implicit: { + const id = try io.readInt(reader, u32); + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + const uri = try decodePageString( + page, + reader, + uri_len, + ); + + break :implicit .{ + .id = .{ .implicit = id }, + .uri = uri, + }; }, - .string_bytes = uri.len, + + .explicit => explicit: { + const id_len: usize = @intCast(try io.readInt(reader, u32)); + const id = try decodePageString( + page, + reader, + id_len, + ); + errdefer if (id.len > 0) page.string_alloc.free( + page.memory, + id.slice(page.memory), + ); + + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + const uri = try decodePageString( + page, + reader, + uri_len, + ); + + break :explicit .{ + .id = .{ .explicit = id }, + .uri = uri, + }; + }, + }; + errdefer entry.free(page); + + return page.hyperlink_set.addContext( + page.memory, + entry, + .{ .page = page }, + ) catch |err| switch (err) { + error.OutOfMemory => error.SetOutOfMemory, + error.NeedsRehash => error.SetNeedsRehash, }; } -fn decodeExplicit( +fn decodePageString( + page: *terminal_page.Page, reader: *std.Io.Reader, - strings: []u8, -) DecodeError!Decoded { - const id_len: usize = @intCast(try io.readInt(reader, u32)); - if (id_len > strings.len) return error.BufferTooSmall; + len: usize, +) (std.Io.Reader.Error || error{StringsOutOfMemory})!terminal_size.Offset(u8).Slice { + if (len == 0) return .{}; - const id = strings[0..id_len]; - try reader.readSliceAll(id); + // Allocate space for the string and read directly into it. + const value = page.string_alloc.alloc( + u8, + page.memory, + len, + ) catch return error.StringsOutOfMemory; + errdefer page.string_alloc.free(page.memory, value); + try reader.readSliceAll(value); - const uri_len: usize = @intCast(try io.readInt(reader, u32)); - const remaining = strings[id.len..]; - if (uri_len > remaining.len) return error.BufferTooSmall; - - const uri = remaining[0..uri_len]; - try reader.readSliceAll(uri); return .{ - .value = .{ - .id = .{ .explicit = id }, - .uri = uri, - }, - .string_bytes = id.len + uri.len, + .len = value.len, + .offset = terminal_size.getOffset( + u8, + page.memory, + value.ptr, + ), }; } @@ -201,7 +230,7 @@ test "golden implicit encoding" { .uri = "uri", }; - var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); @@ -209,7 +238,6 @@ test "golden implicit encoding" { "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", writer.buffered(), ); - try std.testing.expectEqual(@as(usize, 3), try stringBytes(value)); } test "golden explicit encoding" { @@ -218,7 +246,7 @@ test "golden explicit encoding" { .uri = "uri", }; - var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); @@ -226,7 +254,6 @@ test "golden explicit encoding" { "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", writer.buffered(), ); - try std.testing.expectEqual(@as(usize, 5), try stringBytes(value)); } test "empty strings round trip" { @@ -247,18 +274,17 @@ test "empty strings round trip" { try encode(value, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - var strings: [0]u8 = .{}; - const decoded = try decode(&reader, &strings); - try std.testing.expectEqual(@as(usize, 0), decoded.string_bytes); - try std.testing.expectEqualStrings("", decoded.value.uri); + const decoded = try decode(&reader, std.testing.allocator); + defer decoded.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("", decoded.uri); switch (value.id) { .implicit => |id| try std.testing.expectEqual( id, - decoded.value.id.implicit, + decoded.id.implicit, ), .explicit => |id| try std.testing.expectEqualStrings( id, - decoded.value.id.explicit, + decoded.id.explicit, ), } } @@ -272,65 +298,57 @@ test "decode with a one-byte reader buffer" { var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - var implicit_strings: [3]u8 = undefined; - const implicit = try decode(&limited.interface, &implicit_strings); - try std.testing.expectEqual(@as(usize, 3), implicit.string_bytes); - try std.testing.expectEqualStrings("uri", implicit.value.uri); + const implicit = try decode(&limited.interface, std.testing.allocator); + defer implicit.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("uri", implicit.uri); try std.testing.expectEqual( @as(u32, 0x01020304), - implicit.value.id.implicit, + implicit.id.implicit, ); - var explicit_strings: [5]u8 = undefined; - const explicit = try decode(&limited.interface, &explicit_strings); - try std.testing.expectEqual(@as(usize, 5), explicit.string_bytes); - try std.testing.expectEqualStrings("id", explicit.value.id.explicit); - try std.testing.expectEqualStrings("uri", explicit.value.uri); + const explicit = try decode(&limited.interface, std.testing.allocator); + defer explicit.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("id", explicit.id.explicit); + try std.testing.expectEqualStrings("uri", explicit.uri); } test "reject invalid kinds" { inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| { var fixture: [1]u8 = .{kind}; var reader: std.Io.Reader = .fixed(&fixture); - var strings: [0]u8 = .{}; try std.testing.expectError( error.InvalidKind, - decode(&reader, &strings), + decode(&reader, std.testing.allocator), ); } } -test "reject insufficient string storage" { +test "decode allocation failure" { { var reader: std.Io.Reader = .fixed( "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", ); - var strings: [2]u8 = undefined; + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); try std.testing.expectError( - error.BufferTooSmall, - decode(&reader, &strings), + error.OutOfMemory, + decode(&reader, failing.allocator()), ); } - { - var reader: std.Io.Reader = .fixed( - "\x02\x03\x00\x00\x00id!\x03\x00\x00\x00uri", + for (0..2) |fail_index| { + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = fail_index }, ); - var strings: [2]u8 = undefined; - try std.testing.expectError( - error.BufferTooSmall, - decode(&reader, &strings), - ); - } - - { var reader: std.Io.Reader = .fixed( "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", ); - var strings: [4]u8 = undefined; try std.testing.expectError( - error.BufferTooSmall, - decode(&reader, &strings), + error.OutOfMemory, + decode(&reader, failing.allocator()), ); } } @@ -344,10 +362,9 @@ test "reject every truncation" { inline for (fixtures) |fixture| { for (0..fixture.len) |fixture_len| { var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); - var strings: [5]u8 = undefined; try std.testing.expectError( error.EndOfStream, - decode(&reader, &strings), + decode(&reader, std.testing.allocator), ); } } diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 1bdd32e3a..6d5eb9dd4 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -66,16 +66,49 @@ const terminal_hyperlink = @import("../hyperlink.zig"); const terminal_page = @import("../page.zig"); const terminal_style = @import("../style.zig"); +// Frequent constants we use +const TerminalHyperlink = terminal_hyperlink.Hyperlink; +const TerminalHyperlinkPageEntry = terminal_hyperlink.PageEntry; +const TerminalHyperlinkSet = terminal_hyperlink.Set; +const TerminalPage = terminal_page.Page; +const TerminalPageCapacity = terminal_page.Capacity; +const TerminalStyle = terminal_style.Style; +const TerminalStyleId = terminal_style.Id; +const TerminalStyleSet = terminal_style.Set; + /// Errors possible while encoding the native PAGE prefix. pub const EncodeError = hyperlink.EncodeError; +/// Errors possible while decoding the native PAGE prefix. +pub const DecodeError = style.DecodeError || + Header.CapacityError || + error{ + /// The hyperlink kind is not defined by snapshot version 0. + InvalidKind, + + /// The advertised string capacity cannot hold the encoded hyperlinks. + InvalidStringCapacity, + + /// A non-default style was encoded more than once. + DuplicateStyle, + + /// A hyperlink was encoded more than once. + DuplicateHyperlink, + + /// The default style cannot appear in the non-default style table. + DefaultStyle, + + /// Native page backing memory could not be allocated. + OutOfMemory, + }; + /// Encode the PAGE header and lookup tables directly from a native page. /// /// Only the currently implemented PAGE prefix is written. Rows and cells will /// be appended by a later increment. The page is iterated in place and no /// temporary storage is allocated or retained. pub fn encode( - page: *const terminal_page.Page, + page: *const TerminalPage, writer: *std.Io.Writer, ) EncodeError!void { // Write header @@ -94,6 +127,55 @@ pub fn encode( } } +/// Decode the PAGE header and lookup tables directly into a native page. +/// +/// The fixed header is validated before allocating the page. Style entries +/// are inserted directly into the page style set, while hyperlink strings are +/// read into the page string allocator before their native entries are +/// inserted. No caller-owned table or string buffers are required. +/// +/// Only the currently implemented PAGE prefix is consumed. Rows and cells +/// will be decoded by a later increment. +pub fn decode( + reader: *std.Io.Reader, +) DecodeError!TerminalPage { + // Decode the header, validate capacities, init page + const header = try Header.decode(reader); + const capacity = try header.pageCapacity(); + var page = TerminalPage.init(capacity) catch + return error.OutOfMemory; + errdefer page.deinit(); + + // Styles + for (0..header.style_count) |wire_index| { + const value = try style.decode(reader); + if (value.default()) return error.DefaultStyle; + + const native_id = page.styles.add( + page.memory, + value, + ) catch return error.InvalidStyleCapacity; + if (native_id != wire_index + 1) return error.DuplicateStyle; + } + + // Hyperlinks + for (0..header.hyperlink_count) |wire_index| { + const native_id = hyperlink.decodePage( + &page, + reader, + ) catch |err| switch (err) { + error.StringsOutOfMemory => return error.InvalidStringCapacity, + error.SetOutOfMemory, + error.SetNeedsRehash, + => return error.InvalidHyperlinkCapacity, + else => return err, + }; + if (native_id != wire_index + 1) return error.DuplicateHyperlink; + } + + return page; +} + /// The fixed logical dimensions, table counts, and allocation hints at the /// start of PAGE. pub const Header = struct { @@ -167,7 +249,7 @@ pub const Header = struct { /// /// This copies the page's existing allocation capacities. It does not /// scan cells, allocate, or encode any bytes. - fn init(page: *const terminal_page.Page) Header { + fn init(page: *const TerminalPage) Header { return .{ .columns = page.size.cols, .rows = page.size.rows, @@ -180,6 +262,44 @@ pub const Header = struct { }; } + pub const CapacityError = error{ + InvalidDimensions, + InvalidStyleCapacity, + InvalidHyperlinkCapacity, + }; + + /// Validate native allocation requirements and produce the page capacity. + fn pageCapacity(self: Header) CapacityError!TerminalPageCapacity { + if (self.columns == 0 or self.rows == 0) { + return error.InvalidDimensions; + } + + const style_layout: TerminalStyleSet.Layout = .init(self.style_capacity); + if (self.style_count > style_layout.cap -| 1) { + return error.InvalidStyleCapacity; + } + + const hyperlink_capacity_count = @divFloor( + self.hyperlink_capacity_bytes, + @sizeOf(TerminalHyperlinkSet.Item), + ); + const hyperlink_layout: TerminalHyperlinkSet.Layout = .init( + hyperlink_capacity_count, + ); + if (self.hyperlink_count > hyperlink_layout.cap -| 1) { + return error.InvalidHyperlinkCapacity; + } + + return .{ + .cols = self.columns, + .rows = self.rows, + .styles = self.style_capacity, + .hyperlink_bytes = self.hyperlink_capacity_bytes, + .grapheme_bytes = self.grapheme_capacity_bytes, + .string_bytes = self.string_capacity_bytes, + }; + } + fn computeLen() usize { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -198,122 +318,10 @@ pub const Header = struct { } }; -/// The implemented prefix of a PAGE payload: its header and lookup tables. -/// -/// This borrowed representation does not own either table. Encoding writes -/// the header, exactly `header.style_count` non-default styles, and exactly -/// `header.hyperlink_count` unique hyperlinks. Rows and cells will be appended -/// by later increments. -pub const Payload = struct { - header: Header, - styles: []const terminal_style.Style, - hyperlinks: []const terminal_hyperlink.Hyperlink, - - pub const EncodeError = hyperlink.EncodeError || error{ - /// `header.style_count` does not match the provided style slice. - StyleCountMismatch, - - /// The default style cannot appear in the non-default style table. - DefaultStyle, - - /// `header.hyperlink_count` does not match the provided hyperlink slice. - HyperlinkCountMismatch, - }; - - pub const DecodeError = style.DecodeError || hyperlink.DecodeError || error{ - /// `header.style_count` does not match the provided style storage. - StyleCountMismatch, - - /// The default style cannot appear in the non-default style table. - DefaultStyle, - - /// `header.hyperlink_count` does not match the provided hyperlink storage. - HyperlinkCountMismatch, - }; - - /// Caller-owned storage used while decoding the implemented payload prefix. - pub const Buffers = struct { - /// Exact storage for the non-default style table. - styles: []terminal_style.Style, - - /// Exact storage for the unique hyperlink table. - hyperlinks: []terminal_hyperlink.Hyperlink, - - /// Storage for explicit hyperlink IDs and URIs. This must be large - /// enough for the decoded entries; unused trailing bytes are allowed. - hyperlink_strings: []u8, - }; - - /// Encode the PAGE header and its complete style and hyperlink tables. - pub fn encode( - self: Payload, - writer: *std.Io.Writer, - ) Payload.EncodeError!void { - // Some purposeful validation here, don't want to just assert - // this because its very important to get this right for the wire - // format. - if (self.styles.len != self.header.style_count) { - return error.StyleCountMismatch; - } - for (self.styles) |entry| { - if (entry.default()) return error.DefaultStyle; - } - if (self.hyperlinks.len != self.header.hyperlink_count) { - return error.HyperlinkCountMismatch; - } - - // (1) Header - // (2) Styles - // (3) Hyperlinks - try self.header.encode(writer); - for (self.styles) |entry| try style.encode(entry, writer); - for (self.hyperlinks) |entry| try hyperlink.encode(entry, writer); - } - - /// Decode the lookup tables after `header` has already been decoded. - /// - /// Separating header decoding lets the caller inspect the table counts and - /// capacity hints before choosing fixed, stack, pooled, or allocated - /// storage. Every buffer remains owned by the caller. Decoded hyperlink - /// strings borrow from `buffers.hyperlink_strings`. - pub fn decode( - header: Header, - reader: *std.Io.Reader, - buffers: Buffers, - ) DecodeError!Payload { - if (buffers.styles.len != header.style_count) { - return error.StyleCountMismatch; - } - if (buffers.hyperlinks.len != header.hyperlink_count) { - return error.HyperlinkCountMismatch; - } - for (buffers.styles) |*entry| { - entry.* = try style.decode(reader); - if (entry.default()) return error.DefaultStyle; - } - - var string_offset: usize = 0; - for (buffers.hyperlinks) |*entry| { - const decoded = try hyperlink.decode( - reader, - buffers.hyperlink_strings[string_offset..], - ); - entry.* = decoded.value; - string_offset += decoded.string_bytes; - } - - return .{ - .header = header, - .styles = buffers.styles, - .hyperlinks = buffers.hyperlinks, - }; - } -}; - fn pageHyperlink( - page: *const terminal_page.Page, - entry: *const terminal_hyperlink.PageEntry, -) terminal_hyperlink.Hyperlink { + page: *const TerminalPage, + entry: *const TerminalHyperlinkPageEntry, +) TerminalHyperlink { return .{ .id = switch (entry.id) { .implicit => |value| .{ .implicit = value }, @@ -383,7 +391,7 @@ test "reject every truncation" { } test "encode native page lookup tables" { - const capacity: terminal_page.Capacity = .{ + const capacity: TerminalPageCapacity = .{ .cols = 3, .rows = 2, .styles = 8, @@ -391,7 +399,7 @@ test "encode native page lookup tables" { .grapheme_bytes = 128, .string_bytes = 256, }; - var page = try terminal_page.Page.init(capacity); + var page = try TerminalPage.init(capacity); defer page.deinit(); const style_a = try page.styles.add(page.memory, .{ @@ -478,56 +486,17 @@ test "encode native page lookup tables" { try std.testing.expectEqual(@as(u64, fixture.len), counter.count); } -test "payload prefix encodes and decodes lookup tables" { - const styles = [_]terminal_style.Style{ - .{ - .fg_color = .{ .palette = 42 }, - .flags = .{ .bold = true }, - }, - .{ - .bg_color = .{ .rgb = .{ - .r = 0xaa, - .g = 0xbb, - .b = 0xcc, - } }, - .flags = .{ .underline = .double }, - }, - }; - const hyperlinks = [_]terminal_hyperlink.Hyperlink{ - .{ - .id = .{ .implicit = 0x01020304 }, - .uri = "uri", - }, - .{ - .id = .{ .explicit = "id" }, - .uri = "url", - }, - }; +test "decode lookup tables directly into a native page" { const header: Header = .{ .columns = 80, .rows = 24, - .style_count = styles.len, - .hyperlink_count = hyperlinks.len, + .style_count = 2, + .hyperlink_count = 2, .style_capacity = 16, .hyperlink_capacity_bytes = 512, .grapheme_capacity_bytes = 128, .string_capacity_bytes = 256, }; - const payload: Payload = .{ - .header = header, - .styles = &styles, - .hyperlinks = &hyperlinks, - }; - - const encoded_len = comptime len: { - break :len Header.len + - styles.len * style.len + - (hyperlink.encodedLen(hyperlinks[0]) catch unreachable) + - (hyperlink.encodedLen(hyperlinks[1]) catch unreachable); - }; - var encoded: [encoded_len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try payload.encode(&writer); const fixture = "\x50\x00\x18\x00\x02\x00\x02\x00" ++ @@ -539,159 +508,222 @@ test "payload prefix encodes and decodes lookup tables" { "\x00\x00\x00\x00\x00\x02\x00\x00" ++ "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ "\x02\x02\x00\x00\x00id\x03\x00\x00\x00url"; - try std.testing.expectEqualStrings(fixture, writer.buffered()); - var source: std.Io.Reader = .fixed(writer.buffered()); + var source: std.Io.Reader = .fixed(fixture); var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - const decoded_header = try Header.decode(&limited.interface); - var decoded_styles: [styles.len]terminal_style.Style = undefined; - var decoded_hyperlinks: [hyperlinks.len]terminal_hyperlink.Hyperlink = - undefined; - var decoded_hyperlink_strings: [8]u8 = undefined; - const decoded = try Payload.decode( - decoded_header, - &limited.interface, - .{ - .styles = &decoded_styles, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_hyperlink_strings, - }, - ); + var decoded = try decode(&limited.interface); + defer decoded.deinit(); - try std.testing.expectEqual(header, decoded.header); - for (styles, decoded.styles) |expected, actual| { - try std.testing.expect(expected.eql(actual)); - } + try std.testing.expectEqual(header, Header.init(&decoded)); + try decoded.verifyIntegrity(std.testing.allocator); + + var style_it = decoded.styles.iterator(decoded.memory); + const style_a = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 1), style_a.id); + try std.testing.expect((TerminalStyle{ + .fg_color = .{ .palette = 42 }, + .flags = .{ .bold = true }, + }).eql(style_a.value_ptr.*)); + const style_b = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 2), style_b.id); + try std.testing.expect((TerminalStyle{ + .bg_color = .{ .rgb = .{ + .r = 0xaa, + .g = 0xbb, + .b = 0xcc, + } }, + .flags = .{ .underline = .double }, + }).eql(style_b.value_ptr.*)); + try std.testing.expectEqual(null, style_it.next()); + + var hyperlink_it = decoded.hyperlink_set.iterator(decoded.memory); + const hyperlink_a = pageHyperlink( + &decoded, + hyperlink_it.next().?.value_ptr, + ); try std.testing.expectEqual( - hyperlinks[0].id.implicit, - decoded.hyperlinks[0].id.implicit, + @as(u32, 0x01020304), + hyperlink_a.id.implicit, ); - try std.testing.expectEqualStrings( - hyperlinks[0].uri, - decoded.hyperlinks[0].uri, - ); - try std.testing.expectEqualStrings( - hyperlinks[1].id.explicit, - decoded.hyperlinks[1].id.explicit, - ); - try std.testing.expectEqualStrings( - hyperlinks[1].uri, - decoded.hyperlinks[1].uri, + try std.testing.expectEqualStrings("uri", hyperlink_a.uri); + const hyperlink_b = pageHyperlink( + &decoded, + hyperlink_it.next().?.value_ptr, ); + try std.testing.expectEqualStrings("id", hyperlink_b.id.explicit); + try std.testing.expectEqualStrings("url", hyperlink_b.uri); + try std.testing.expectEqual(null, hyperlink_it.next()); + + var reencoded: [fixture.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&reencoded); + try encode(&decoded, &writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); } -test "payload prefix validates its style table" { - const non_default = [_]terminal_style.Style{ - .{ .flags = .{ .bold = true } }, +test "decode validates dimensions and native table capacities" { + const cases = .{ + .{ + .expected = error.InvalidDimensions, + .header = Header{ + .columns = 0, + .rows = 24, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }, + .{ + .expected = error.InvalidDimensions, + .header = Header{ + .columns = 80, + .rows = 0, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }, + .{ + .expected = error.InvalidStyleCapacity, + .header = Header{ + .columns = 80, + .rows = 24, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }, + .{ + .expected = error.InvalidHyperlinkCapacity, + .header = Header{ + .columns = 80, + .rows = 24, + .style_count = 0, + .hyperlink_count = 1, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }, }; + + inline for (cases) |case| { + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try case.header.encode(&writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError(case.expected, decode(&reader)); + } +} + +test "decode rejects duplicate and default style entries" { const header: Header = .{ .columns = 80, .rows = 24, .style_count = 2, .hyperlink_count = 0, - .style_capacity = 0, - .hyperlink_capacity_bytes = 0, + .style_capacity = 16, + .hyperlink_capacity_bytes = 512, .grapheme_capacity_bytes = 0, - .string_capacity_bytes = 0, + .string_capacity_bytes = 256, + }; + const duplicate_style: TerminalStyle = .{ + .flags = .{ .bold = true }, }; - var encoded: [Header.len + style.len]u8 = undefined; + var encoded: [Header.len + 2 * style.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.StyleCountMismatch, - (Payload{ - .header = header, - .styles = &non_default, - .hyperlinks = &.{}, - }).encode(&writer), - ); - try std.testing.expectEqual(@as(usize, 0), writer.end); + try header.encode(&writer); + try style.encode(duplicate_style, &writer); + try style.encode(duplicate_style, &writer); - var reader: std.Io.Reader = .fixed(&.{}); - var decoded_styles: [1]terminal_style.Style = undefined; - var decoded_hyperlinks: [0]terminal_hyperlink.Hyperlink = .{}; - var decoded_hyperlink_strings: [0]u8 = .{}; - try std.testing.expectError( - error.StyleCountMismatch, - Payload.decode(header, &reader, .{ - .styles = &decoded_styles, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_hyperlink_strings, - }), - ); + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError(error.DuplicateStyle, decode(&reader)); - const default_styles = [_]terminal_style.Style{.{}}; const default_header: Header = .{ .columns = 80, .rows = 24, .style_count = 1, .hyperlink_count = 0, - .style_capacity = 0, + .style_capacity = 16, .hyperlink_capacity_bytes = 0, .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - try std.testing.expectError( - error.DefaultStyle, - (Payload{ - .header = default_header, - .styles = &default_styles, - .hyperlinks = &.{}, - }).encode(&writer), - ); + var default_encoded: [Header.len + style.len]u8 = undefined; + var default_writer: std.Io.Writer = .fixed(&default_encoded); + try default_header.encode(&default_writer); + try style.encode(.{}, &default_writer); - var default_fixture: [style.len]u8 = @splat(0); - var default_reader: std.Io.Reader = .fixed(&default_fixture); - var decoded_default: [1]terminal_style.Style = undefined; - try std.testing.expectError( - error.DefaultStyle, - Payload.decode(default_header, &default_reader, .{ - .styles = &decoded_default, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_hyperlink_strings, - }), - ); + var default_reader: std.Io.Reader = .fixed(default_writer.buffered()); + try std.testing.expectError(error.DefaultStyle, decode(&default_reader)); } -test "payload prefix validates its hyperlink table" { - const hyperlinks = [_]terminal_hyperlink.Hyperlink{.{ - .id = .{ .implicit = 42 }, - .uri = "uri", - }}; +test "decode rejects duplicate hyperlinks with empty strings" { const header: Header = .{ - .columns = 80, - .rows = 24, + .columns = 1, + .rows = 1, .style_count = 0, .hyperlink_count = 2, .style_capacity = 0, - .hyperlink_capacity_bytes = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + const duplicate: TerminalHyperlink = .{ + .id = .{ .explicit = "" }, + .uri = "", + }; + + var encoded: [Header.len + 18]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try hyperlink.encode(duplicate, &writer); + try hyperlink.encode(duplicate, &writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError(error.DuplicateHyperlink, decode(&reader)); +} + +test "decode reads hyperlink strings into page storage" { + const link: TerminalHyperlink = .{ + .id = .{ .explicit = "id" }, + .uri = "uri", + }; + const hyperlink_capacity: u16 = @intCast( + TerminalHyperlinkSet.capacityForCount(1) * + @sizeOf(TerminalHyperlinkSet.Item), + ); + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 1, + .style_capacity = 0, + .hyperlink_capacity_bytes = hyperlink_capacity, .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - var encoded: [Header.len + 12]u8 = undefined; + var encoded: [Header.len + 14]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.HyperlinkCountMismatch, - (Payload{ - .header = header, - .styles = &.{}, - .hyperlinks = &hyperlinks, - }).encode(&writer), - ); - try std.testing.expectEqual(@as(usize, 0), writer.end); + try header.encode(&writer); + try hyperlink.encode(link, &writer); - var empty_reader: std.Io.Reader = .fixed(&.{}); - var decoded_styles: [0]terminal_style.Style = .{}; - var decoded_hyperlinks: [1]terminal_hyperlink.Hyperlink = undefined; - var decoded_strings: [3]u8 = undefined; + var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( - error.HyperlinkCountMismatch, - Payload.decode(header, &empty_reader, .{ - .styles = &decoded_styles, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_strings, - }), + error.InvalidStringCapacity, + decode(&reader), ); }