From a74feaf715c026ad75bb471830f9c385514f3739 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 27 Jul 2026 13:37:09 -0700 Subject: [PATCH] terminal/snapshot: encode sparse page grids --- src/terminal/snapshot/grid.zig | 562 +++++++++++++++++++++++ src/terminal/snapshot/hyperlink.zig | 15 +- src/terminal/snapshot/main.zig | 5 + src/terminal/snapshot/page.zig | 668 +++++++++++++++++++++++----- 4 files changed, 1135 insertions(+), 115 deletions(-) create mode 100644 src/terminal/snapshot/grid.zig diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig new file mode 100644 index 000000000..18d45b838 --- /dev/null +++ b/src/terminal/snapshot/grid.zig @@ -0,0 +1,562 @@ +//! Grid (rows and cells) encoding. +//! +//! A grid contains the rows and cells of a terminal page. Its dimensions are +//! supplied by the containing record rather than repeated here. The encoder +//! writes exactly `rows` row records, and every row contains exactly `columns` +//! cell records. +//! +//! All records are tightly packed with no padding between them. All integers +//! are unsigned and little-endian. +//! +//! ## Row +//! +//! Each row has the following format: +//! +//! | Offset | Size | Field | +//! | -----: | -------: | :-------------------------- | +//! | 0 | 1 | Row flags | +//! | 1 | variable | Exactly `columns` cells | +//! +//! Cells are encoded consecutively. Since cells may contain a variable number +//! of grapheme codepoints, the next row begins immediately after the final +//! cell and its grapheme codepoints. +//! +//! The row flag byte has the following format: +//! +//! | Bits | Field | +//! | ---: | :------------------------ | +//! | 0 | Wrap | +//! | 1 | Wrap continuation | +//! | 2-3 | Semantic prompt | +//! | 4-7 | Reserved, zero | +//! +//! Semantic prompt values are: +//! +//! | Value | Meaning | +//! | ----: | :------------------ | +//! | 0 | None | +//! | 1 | Prompt | +//! | 2 | Prompt continuation | +//! +//! Value 3 is invalid in snapshot version 0. +//! +//! ## Cell +//! +//! Each cell has the following format: +//! +//! | Offset | Size | Field | +//! | -----: | --------: | :---------------------------- | +//! | 0 | 1 | Content kind | +//! | 1 | 1 | Width kind | +//! | 2 | 1 | Protected and semantic flags | +//! | 3 | 1 | Reserved, zero | +//! | 4 | 2 | Style ID | +//! | 6 | 2 | Hyperlink ID | +//! | 8 | 4 | Codepoint or packed color | +//! | 12 | 4 | Grapheme suffix count | +//! | 16 | 4 * count | Grapheme suffix codepoints | +//! +//! Content kinds are: +//! +//! | Value | Meaning | +//! | ----: | :----------------- | +//! | 0 | Codepoint | +//! | 1 | Palette background | +//! | 2 | RGB background | +//! +//! For codepoint content, the value is a Unicode scalar encoded as a `u32`. +//! For a palette background, the low byte is the palette index and the other +//! three bytes are zero. For an RGB background, the low three bytes are red, +//! green, and blue, and the high byte is zero. +//! +//! Width kinds are: +//! +//! | Value | Meaning | +//! | ----: | :---------- | +//! | 0 | Narrow | +//! | 1 | Wide | +//! | 2 | Spacer tail | +//! | 3 | Spacer head | +//! +//! The cell flag byte has the following format: +//! +//! | Bits | Field | +//! | ---: | :---------------- | +//! | 0 | Protected | +//! | 1-2 | Semantic content | +//! | 3-7 | Reserved, zero | +//! +//! Semantic content values are: +//! +//! | Value | Meaning | +//! | ----: | :------ | +//! | 0 | Output | +//! | 1 | Input | +//! | 2 | Prompt | +//! +//! Value 3 is invalid in snapshot version 0. +//! +//! Style and hyperlink ID zero mean no style and no hyperlink. Other IDs +//! refer to entries in the containing record's separate style and hyperlink +//! tables. +//! +//! Grapheme suffixes are valid only for codepoint content. Each suffix is a +//! Unicode scalar encoded as a `u32`. A nonzero suffix count requires a +//! nonzero base codepoint. + +const std = @import("std"); +const io = @import("io.zig"); +const kitty = @import("../kitty.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_page = @import("../page.zig"); +const terminal_style = @import("../style.zig"); + +const TerminalCell = terminal_page.Cell; +const TerminalHyperlinkId = terminal_hyperlink.Id; +const TerminalPage = terminal_page.Page; +const TerminalRow = terminal_page.Row; +const TerminalStyleId = terminal_style.Id; + +/// Maps encoded style table IDs to IDs assigned by the destination page. +/// +/// Build this by inserting each decoded style into the page, then recording +/// the encoded ID and the ID returned by the page's style set. Style ID zero is +/// implicit and does not need an entry. +pub const StyleRemap = std.AutoHashMap( + TerminalStyleId, + TerminalStyleId, +); + +/// Maps encoded hyperlink table IDs to IDs assigned by the destination page. +/// +/// Build this by inserting each decoded hyperlink into the page, then +/// recording the encoded ID and the ID returned by the page's hyperlink set. +/// Hyperlink ID zero is implicit and does not need an entry. +pub const HyperlinkRemap = std.AutoHashMap( + TerminalHyperlinkId, + TerminalHyperlinkId, +); + +pub const EncodeError = std.Io.Writer.Error; + +/// Encode every row and cell directly from a page. +pub fn encode( + page: *const TerminalPage, + writer: *std.Io.Writer, +) EncodeError!void { + // Encoding assumes a structurally valid page. This assertion performs the + // comprehensive check in slow-safety builds without adding an allocator to + // the encoder API. + page.assertIntegrity(); + + for (0..page.size.rows) |y| { + // Row header + const row = page.getRow(y); + const row_header: RowHeader = .{ + .wrap = row.wrap, + .wrap_continuation = row.wrap_continuation, + .semantic_prompt = switch (row.semantic_prompt) { + .none => .none, + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + }, + }; + try writer.writeByte(@bitCast(row_header)); + + // Cells + const cells = page.getCells(row); + for (cells) |*cell| { + const graphemes: []const u21 = if (cell.hasGrapheme()) + page.lookupGrapheme(cell) orelse unreachable + else + &.{}; + + // The page has two codepoint tags depending on whether suffixes + // exist, but the wire represents both with one content kind. + const kind: CellHeader.Kind = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => .codepoint, + .bg_color_palette => .bg_color_palette, + .bg_color_rgb => .bg_color_rgb, + }; + const value: CellHeader.Value = switch (kind) { + .codepoint => .{ + .codepoint = cell.content.codepoint.data, + }, + .bg_color_palette => .{ .bg_color_palette = .{ + .index = cell.content.color_palette.data, + } }, + .bg_color_rgb => .{ .bg_color_rgb = .{ + .r = cell.content.color_rgb.r, + .g = cell.content.color_rgb.g, + .b = cell.content.color_rgb.b, + } }, + }; + + const style_id = cell.style_id; + const hyperlink_id: TerminalHyperlinkId = if (cell.hyperlink) + page.lookupHyperlink(cell) orelse unreachable + else + 0; + + const header: CellHeader = .{ + .content_kind = kind, + .width = cell.wide, + .protected = cell.protected, + .semantic_content = cell.semantic_content, + .style_id = style_id, + .hyperlink_id = hyperlink_id, + .value = value, + .grapheme_count = @intCast(graphemes.len), + }; + try header.encode(writer); + for (graphemes) |suffix| try io.writeInt( + writer, + u32, + suffix, + ); + } + } +} + +pub const DecodeError = CellHeader.DecodeError || error{ + /// A row contains undefined flag values. + InvalidRow, + + /// A cell contains an undefined kind, width, flag, or reserved value. + InvalidCell, + + /// A grapheme suffix is invalid or attached to non-codepoint content. + InvalidGrapheme, + + /// The advertised grapheme capacity cannot hold the encoded suffixes. + InvalidGraphemeCapacity, + + /// A codepoint is not a Unicode scalar value. + InvalidCodepoint, + + /// A packed background color has nonzero reserved bytes. + InvalidColor, + + /// Wide and spacer cells do not form a valid row. + InvalidWideCell, + + /// The hyperlink map cannot hold the encoded cell references. + InvalidHyperlinkCapacity, + + /// PAGE records do not support Kitty graphics placeholders. + UnsupportedKittyGraphics, +}; + +/// Decode every row and cell directly into an initialized, empty page. +/// +/// The grid does not encode dimensions, so `page` must already have the exact +/// row and column count expected by the containing record. This function reads +/// exactly `page.size.rows` rows with `page.size.cols` cells each. The page +/// must also have enough grapheme and hyperlink-map capacity for the decoded +/// contents. +/// +/// Style and hyperlink table entries must be inserted into `page` before +/// calling this function. As each table entry is inserted, the caller records +/// its encoded ID and page-assigned ID in `style_remap` or `hyperlink_remap`. +/// ID zero always means the default style or no hyperlink. A nonzero ID missing +/// from its remap is also treated as zero so unknown table references do not +/// prevent the rest of the grid from decoding. +pub fn decode( + page: *TerminalPage, + reader: *std.Io.Reader, + style_remap: *const StyleRemap, + hyperlink_remap: *const HyperlinkRemap, +) DecodeError!void { + for (0..page.size.rows) |y| { + const row_header: RowHeader = @bitCast(try reader.takeByte()); + if (row_header._padding != 0) return error.InvalidRow; + const semantic_prompt: TerminalRow.SemanticPrompt = + switch (row_header.semantic_prompt) { + .none => .none, + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + .invalid => return error.InvalidRow, + }; + + const row = page.getRow(y); + row.wrap = row_header.wrap; + row.wrap_continuation = row_header.wrap_continuation; + row.semantic_prompt = semantic_prompt; + + const cells = page.getCells(row); + for (cells, 0..) |*cell, x| { + const header = try CellHeader.decode(reader); + + // IDs belong to the encoded page. Translate them to IDs assigned + // by the destination page before storing them on cells. + const encoded_style_id = header.style_id; + const style_id = if (encoded_style_id == 0) + 0 + else + style_remap.get(encoded_style_id) orelse 0; + + const encoded_hyperlink_id = header.hyperlink_id; + const hyperlink_id = if (encoded_hyperlink_id == 0) + 0 + else + hyperlink_remap.get(encoded_hyperlink_id) orelse 0; + + cell.* = .init(0); + switch (header.content_kind) { + .codepoint => { + const cp = std.math.cast(u21, header.value.codepoint) orelse + return error.InvalidCodepoint; + if (!validCodepoint(cp)) return error.InvalidCodepoint; + if (cp == kitty.graphics.unicode.placeholder) { + return error.UnsupportedKittyGraphics; + } + if (header.grapheme_count > 0 and cp == 0) { + return error.InvalidGrapheme; + } + cell.content = .{ .codepoint = .{ .data = cp } }; + }, + .bg_color_palette => { + const palette = header.value.bg_color_palette; + if (palette._padding != 0) return error.InvalidColor; + if (header.grapheme_count != 0) { + return error.InvalidGrapheme; + } + cell.content_tag = .bg_color_palette; + cell.content = .{ + .color_palette = .{ .data = palette.index }, + }; + }, + .bg_color_rgb => { + const rgb = header.value.bg_color_rgb; + if (rgb._padding != 0) return error.InvalidColor; + if (header.grapheme_count != 0) { + return error.InvalidGrapheme; + } + cell.content_tag = .bg_color_rgb; + cell.content = .{ .color_rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }; + }, + } + + cell.wide = header.width; + cell.protected = header.protected; + cell.semantic_content = header.semantic_content; + + if (style_id != 0) { + // The table owns one reference and each decoded cell owns one + // additional reference. + page.styles.use(page.memory, style_id); + cell.style_id = style_id; + row.styled = true; + } + + if (hyperlink_id != 0) { + // setHyperlink records the cell mapping but intentionally does + // not increment the set's reference count. + page.hyperlink_set.use(page.memory, hyperlink_id); + page.setHyperlink(row, cell, hyperlink_id) catch + return error.InvalidHyperlinkCapacity; + } + + // Append directly into page-owned grapheme storage so no + // intermediate suffix buffer is needed. + for (0..header.grapheme_count) |_| { + const suffix_raw = try io.readInt(reader, u32); + const suffix = std.math.cast(u21, suffix_raw) orelse + return error.InvalidCodepoint; + if (!validCodepoint(suffix)) return error.InvalidCodepoint; + if (suffix == kitty.graphics.unicode.placeholder) { + return error.UnsupportedKittyGraphics; + } + page.appendGrapheme(row, cell, suffix) catch + return error.InvalidGraphemeCapacity; + } + + switch (header.width) { + .narrow, .wide => {}, + .spacer_tail => if (x == 0 or + cells[x - 1].wide != .wide) + { + return error.InvalidWideCell; + }, + .spacer_head => if (x + 1 != cells.len or !row.wrap) { + return error.InvalidWideCell; + }, + } + } + } +} + +/// The header before every row. +const RowHeader = packed struct(u8) { + wrap: bool = false, + wrap_continuation: bool = false, + semantic_prompt: SemanticPrompt = .none, + _padding: u4 = 0, + + const SemanticPrompt = enum(u2) { + none = 0, + prompt = 1, + prompt_continuation = 2, + invalid = 3, + }; +}; + +/// The fixed fields that precede a cell's grapheme suffix codepoints. +pub const CellHeader = struct { + /// Number of bytes written by `encode`, calculated using the encoder itself + /// so this remains synchronized with the field-by-field wire format. + pub const len = computeLen(); + + comptime { + // This size is part of the wire format. If it changes, the snapshot + // version and golden fixtures must also change. + std.debug.assert(len == 16); + } + + /// Interpretation of `value`. + content_kind: Kind = .codepoint, + + /// Display width and spacer role of the cell. + width: TerminalCell.Wide = .narrow, + + /// Whether selective erase operations protect the cell. + protected: bool = false, + + /// Semantic role assigned by shell integration. + semantic_content: TerminalCell.SemanticContent = .output, + + /// ID in the encoded page's style table, or zero for the default style. + style_id: TerminalStyleId = 0, + + /// ID in the encoded page's hyperlink table, or zero for no hyperlink. + hyperlink_id: TerminalHyperlinkId = 0, + + /// Codepoint or packed background color selected by `content_kind`. + value: Value = .{ .codepoint = 0 }, + + /// Number of grapheme suffix codepoints immediately following the header. + grapheme_count: u32 = 0, + + /// Errors possible while decoding a fixed cell header. + pub const DecodeError = std.Io.Reader.Error || error{InvalidCell}; + + /// Encode the fixed cell header. + pub fn encode( + self: CellHeader, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const semantic_content: Flags.SemanticContent = switch (self.semantic_content) { + .output => .output, + .input => .input, + .prompt => .prompt, + }; + const flags: Flags = .{ + .protected = self.protected, + .semantic_content = semantic_content, + }; + + // 0 1 2 3 4 6 8 12 16 + // +-------+-------+-------+-------+--------+--------+--------+--------+ + // | kind | width | flags | zero | style | link | value | count | + // | u8 | u8 | u8 | u8 | u16 LE | u16 LE | u32 LE | u32 LE | + // +-------+-------+-------+-------+--------+--------+--------+--------+ + try writer.writeByte(@intFromEnum(self.content_kind)); + try writer.writeByte(@intFromEnum(self.width)); + try writer.writeByte(@bitCast(flags)); + try writer.writeByte(0); + try io.writeInt(writer, TerminalStyleId, self.style_id); + try io.writeInt(writer, TerminalHyperlinkId, self.hyperlink_id); + try io.writeInt(writer, u32, @bitCast(self.value)); + try io.writeInt(writer, u32, self.grapheme_count); + } + + /// Decode and validate the fixed cell header. + pub fn decode(reader: *std.Io.Reader) CellHeader.DecodeError!CellHeader { + const content_kind = std.enums.fromInt( + Kind, + try reader.takeByte(), + ) orelse return error.InvalidCell; + + const width = std.enums.fromInt( + TerminalCell.Wide, + try reader.takeByte(), + ) orelse return error.InvalidCell; + + const flags: Flags = @bitCast(try reader.takeByte()); + if (flags._padding != 0) return error.InvalidCell; + const semantic_content: TerminalCell.SemanticContent = switch (flags.semantic_content) { + .output => .output, + .input => .input, + .prompt => .prompt, + .invalid => return error.InvalidCell, + }; + + // This byte is reserved so the IDs and content value remain naturally + // aligned within the fixed cell header. + if (try reader.takeByte() != 0) return error.InvalidCell; + + return .{ + .content_kind = content_kind, + .width = width, + .protected = flags.protected, + .semantic_content = semantic_content, + .style_id = try io.readInt(reader, TerminalStyleId), + .hyperlink_id = try io.readInt(reader, TerminalHyperlinkId), + .value = @bitCast(try io.readInt(reader, u32)), + .grapheme_count = try io.readInt(reader, u32), + }; + } + + /// Computes the fixed header size using the encoder itself. + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + CellHeader.encode(.{}, &writer) catch unreachable; + return writer.end; + } + } + + /// Determines how `value` is interpreted. + pub const Kind = enum(u8) { + codepoint = 0, + bg_color_palette = 1, + bg_color_rgb = 2, + }; + + /// The alternate interpretations of the four-byte content field. + pub const Value = packed union(u32) { + codepoint: u32, + bg_color_palette: packed struct(u32) { + index: u8, + _padding: u24 = 0, + }, + bg_color_rgb: packed struct(u32) { + r: u8, + g: u8, + b: u8, + _padding: u8 = 0, + }, + }; + + const Flags = packed struct(u8) { + protected: bool = false, + semantic_content: SemanticContent = .output, + _padding: u5 = 0, + + const SemanticContent = enum(u2) { + output = 0, + input = 1, + prompt = 2, + invalid = 3, + }; + }; +}; + +fn validCodepoint(cp: u21) bool { + return cp <= 0x10FFFF and (cp < 0xD800 or cp > 0xDFFF); +} diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 01e36702c..5f2503c54 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -64,6 +64,9 @@ pub const DecodePageError = std.Io.Reader.Error || error{ /// The hyperlink kind is not defined by snapshot version 0. InvalidKind, + + /// The hyperlink value already exists in the page. + DuplicateHyperlink, }; /// Encode one hyperlink entry. @@ -136,7 +139,7 @@ pub fn decode( /// /// 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. +/// the native ID assigned by the destination page. pub fn decodePage( page: *terminal_page.Page, reader: *std.Io.Reader, @@ -188,6 +191,14 @@ pub fn decodePage( }; errdefer entry.free(page); + if (page.hyperlink_set.lookupContext( + page.memory, + entry, + .{ .page = page }, + ) != null) { + return error.DuplicateHyperlink; + } + return page.hyperlink_set.addContext( page.memory, entry, @@ -219,7 +230,7 @@ fn decodePageString( .offset = terminal_size.getOffset( u8, page.memory, - value.ptr, + &value[0], ), }; } diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 46921d7d4..fedcc4618 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -44,7 +44,12 @@ //! CONTINUATION, READY, and FINISH. pub const envelope = @import("envelope.zig"); +pub const grid = @import("grid.zig"); pub const hyperlink = @import("hyperlink.zig"); pub const page = @import("page.zig"); pub const record = @import("record.zig"); pub const style = @import("style.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 6d5eb9dd4..1e18ced22 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -40,9 +40,9 @@ //! //! Next, the style count and hyperlink count denote the number of //! styles and hyperlinks respectively that are sent with the page. -//! The default style and absence of a hyperlink are implicit at wire -//! index zero and are not included in these counts. Encoded table entries -//! receive one-based wire indexes in their encoded order. +//! The default style and absence of a hyperlink are implicit at native +//! ID zero and are not included in these counts. Each encoded table entry +//! carries its nonzero native page ID. //! //! The final four fields are allocation hints copied from the source page. //! These represent upper limits on what this page might contain. A decoder @@ -51,14 +51,50 @@ //! //! ### Payload //! -//! This is still a work-in-progress. The current implementation encodes and -//! decodes the header, style table, and hyperlink table only. It does not yet -//! produce or consume a complete PAGE payload. +//! ```text +//! +---------------------------+ +//! | Header | +//! | 20 bytes | +//! +---------------------------+ +//! | Style table | +//! | style_count entries | +//! +---------------------------+ +//! | Hyperlink table | +//! | hyperlink_count entries | +//! +---------------------------+ +//! | Grid | +//! | one record per row | +//! +---------------------------+ //! -//! Following the header, data is tightly packed in the following order: -//! styles, hyperlinks, cells. TODO! +//! Style entry = encoded ID + style record +//! Hyperlink entry = encoded ID + hyperlink record +//! Grid = row 0 ... row (rows - 1) +//! Row = row header + cell 0 ... cell (columns - 1) +//! Cell = cell header + grapheme suffix codepoints +//! ``` +//! +//! Following the header, the payload contains exactly `style_count` style +//! records and `hyperlink_count` hyperlink records, followed by exactly +//! `rows` row records. There is no padding between records. +//! +//! Each style begins with an ID (`u16`) followed by the typical style +//! binary representation (in style.zig). The ID is what cells will use +//! to reference the style. Decoders must maintain some kind of mapping +//! in order to associate these properly. +//! +//! Each hyperlink also begins with an ID (`u16`) with the same semantics +//! as the style ID. The hyperlink and style IDs are separate namespaces, +//! so they may collide. Following the ID, the hyperlink binary format +//! is encoded directly. +//! +//! Both style and hyperlink IDs are not guaranteed to be in any specific +//! order and may contain gaps (e.g. ID 1 and 3 but not 2). +//! +//! Rows and cells use the grid encoding documented in `grid.zig`. const std = @import("std"); +const Allocator = std.mem.Allocator; +const grid = @import("grid.zig"); const hyperlink = @import("hyperlink.zig"); const io = @import("io.zig"); const style = @import("style.zig"); @@ -68,19 +104,23 @@ const terminal_style = @import("../style.zig"); // Frequent constants we use const TerminalHyperlink = terminal_hyperlink.Hyperlink; +const TerminalHyperlinkId = terminal_hyperlink.Id; const TerminalHyperlinkPageEntry = terminal_hyperlink.PageEntry; const TerminalHyperlinkSet = terminal_hyperlink.Set; +const TerminalCell = terminal_page.Cell; const TerminalPage = terminal_page.Page; const TerminalPageCapacity = terminal_page.Capacity; +const TerminalRow = terminal_page.Row; 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 encoding a native PAGE. +pub const EncodeError = hyperlink.EncodeError || grid.EncodeError; -/// Errors possible while decoding the native PAGE prefix. +/// Errors possible while decoding a native PAGE. pub const DecodeError = style.DecodeError || + grid.DecodeError || Header.CapacityError || error{ /// The hyperlink kind is not defined by snapshot version 0. @@ -98,15 +138,15 @@ pub const DecodeError = style.DecodeError || /// The default style cannot appear in the non-default style table. DefaultStyle, + /// An encoded table ID is zero or appears more than once. + InvalidStyleId, + InvalidHyperlinkId, + /// 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. +/// Encode a complete PAGE directly from a native page. pub fn encode( page: *const TerminalPage, writer: *std.Io.Writer, @@ -114,30 +154,31 @@ pub fn encode( // Write header try Header.init(page).encode(writer); - // Packed styles + // Sparse styles var style_it = page.styles.iterator(page.memory); while (style_it.next()) |entry| { + try io.writeInt(writer, TerminalStyleId, entry.id); try style.encode(entry.value_ptr.*, writer); } - // Packed hyperlinks + // Sparse hyperlinks var hyperlink_it = page.hyperlink_set.iterator(page.memory); while (hyperlink_it.next()) |entry| { + try io.writeInt(writer, TerminalHyperlinkId, entry.id); try hyperlink.encode(pageHyperlink(page, entry.value_ptr), writer); } + + // Rows and cells + try grid.encode(page, writer); } -/// Decode the PAGE header and lookup tables directly into a native page. +/// Decode a complete PAGE directly into a fresh 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. +/// `alloc` is used only for the temporary native-ID remap tables. Styles, +/// hyperlinks, strings, graphemes, rows, and cells are stored in the page. pub fn decode( reader: *std.Io.Reader, + alloc: Allocator, ) DecodeError!TerminalPage { // Decode the header, validate capacities, init page const header = try Header.decode(reader); @@ -145,22 +186,65 @@ pub fn decode( var page = TerminalPage.init(capacity) catch return error.OutOfMemory; errdefer page.deinit(); + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + + var style_remap = grid.StyleRemap.init(alloc); + defer style_remap.deinit(); + style_remap.ensureTotalCapacity(header.style_count) catch + return error.OutOfMemory; + + var hyperlink_remap = grid.HyperlinkRemap.init(alloc); + defer hyperlink_remap.deinit(); + hyperlink_remap.ensureTotalCapacity(header.hyperlink_count) catch + return error.OutOfMemory; // Styles - for (0..header.style_count) |wire_index| { + for (0..header.style_count) |_| { + // Zero denotes the implicit default style. Reusing an encoded ID would + // make cell references ambiguous because it would name multiple table + // entries. + const native_id = try io.readInt(reader, TerminalStyleId); + if (native_id == 0 or style_remap.contains(native_id)) { + return error.InvalidStyleId; + } + + // Decode the style itself. It must never be the default style. const value = try style.decode(reader); if (value.default()) return error.DefaultStyle; - const native_id = page.styles.add( + // If we already have the style, its invalid. + if (page.styles.lookup( page.memory, value, - ) catch return error.InvalidStyleCapacity; - if (native_id != wire_index + 1) return error.DuplicateStyle; + ) != null) return error.DuplicateStyle; + + // Add our style, get our real ID on this side, and store it in the + // remap table. + const decoded_id = page.styles.add( + page.memory, + value, + ) catch |err| switch (err) { + error.OutOfMemory, + error.NeedsRehash, + => return error.InvalidStyleCapacity, + }; + style_remap.putAssumeCapacityNoClobber( + native_id, + decoded_id, + ); } // Hyperlinks - for (0..header.hyperlink_count) |wire_index| { - const native_id = hyperlink.decodePage( + for (0..header.hyperlink_count) |_| { + // Zero denotes no hyperlink. As with styles, a repeated encoded ID + // would make cell references ambiguous. + const native_id = try io.readInt(reader, TerminalHyperlinkId); + if (native_id == 0 or hyperlink_remap.contains(native_id)) { + return error.InvalidHyperlinkId; + } + + const decoded_id = hyperlink.decodePage( &page, reader, ) catch |err| switch (err) { @@ -168,11 +252,26 @@ pub fn decode( error.SetOutOfMemory, error.SetNeedsRehash, => return error.InvalidHyperlinkCapacity, - else => return err, + + error.DuplicateHyperlink => return error.DuplicateHyperlink, + error.InvalidKind => return error.InvalidKind, + error.EndOfStream => return error.EndOfStream, + error.ReadFailed => return error.ReadFailed, }; - if (native_id != wire_index + 1) return error.DuplicateHyperlink; + hyperlink_remap.putAssumeCapacityNoClobber( + native_id, + decoded_id, + ); } + // Rows and cells + try grid.decode( + &page, + reader, + &style_remap, + &hyperlink_remap, + ); + return page; } @@ -331,6 +430,21 @@ fn pageHyperlink( }; } +const test_page_fixture = + "\x03\x00\x02\x00\x02\x00\x02\x00\x08\x00\x00\x02\x80\x00\x00\x00" ++ + "\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x00\x00\x03\x00\x00\x00\x00\x00\x01\x2a\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x01\x00\x00\x00\x61" ++ + "\x05\x00\x00\x00\x61\x6c\x70\x68\x61\x03\x00\x01\x04\x03\x02\x01" ++ + "\x04\x00\x00\x00\x62\x65\x74\x61\x04\x00\x01\x05\x00\x01\x00\x01" ++ + "\x00\x41\x00\x00\x00\x00\x00\x00\x00\x00\x02\x02\x00\x03\x00\x03" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01" ++ + "\x00\x07\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x78\x00\x00\x00\x02\x00\x00\x00\x01\x03\x00\x00\x02\x03" ++ + "\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\xaa\xbb\xcc\x00\x00\x00" ++ + "\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00"; + test "golden encoding" { const header: Header = .{ .columns = 0x0102, @@ -390,7 +504,7 @@ test "reject every truncation" { } } -test "encode native page lookup tables" { +test "encode and decode a sparse native page" { const capacity: TerminalPageCapacity = .{ .cols = 3, .rows = 2, @@ -453,6 +567,33 @@ test "encode native page lookup tables" { &.{ 0x0301, 0x0302 }, ); + first.cell.content = .{ .codepoint = .{ .data = 'A' } }; + first.cell.wide = .wide; + first.cell.protected = true; + first.cell.semantic_content = .prompt; + first.row.semantic_prompt = .prompt; + + second.cell.wide = .spacer_tail; + second.cell.semantic_content = .input; + + third.cell.content_tag = .bg_color_palette; + third.cell.content = .{ .color_palette = .{ .data = 7 } }; + + const rgb = page.getRowAndCell(1, 1); + rgb.cell.content_tag = .bg_color_rgb; + rgb.cell.content = .{ .color_rgb = .{ + .r = 0xaa, + .g = 0xbb, + .b = 0xcc, + } }; + rgb.cell.protected = true; + + const spacer_head = page.getRowAndCell(2, 1); + spacer_head.cell.wide = .spacer_head; + spacer_head.row.wrap = true; + spacer_head.row.wrap_continuation = true; + spacer_head.row.semantic_prompt = .prompt_continuation; + const header: Header = .{ .columns = 3, .rows = 2, @@ -468,97 +609,380 @@ test "encode native page lookup tables" { var counter: std.Io.Writer.Discarding = .init(&.{}); try encode(&page, &counter.writer); - var encoded: [128]u8 = undefined; + var encoded: [512]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try encode(&page, &writer); - const fixture = - "\x03\x00\x02\x00\x02\x00\x02\x00" ++ - "\x08\x00\x00\x02\x80\x00\x00\x00" ++ - "\x00\x01\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x2a\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x02\x01\x00\x00\x00a\x05\x00\x00\x00alpha" ++ - "\x01\x04\x03\x02\x01\x04\x00\x00\x00beta"; + const fixture = test_page_fixture; try std.testing.expectEqualStrings(fixture, writer.buffered()); try std.testing.expectEqual(@as(u64, fixture.len), counter.count); -} -test "decode lookup tables directly into a native page" { - const header: Header = .{ - .columns = 80, - .rows = 24, - .style_count = 2, - .hyperlink_count = 2, - .style_capacity = 16, - .hyperlink_capacity_bytes = 512, - .grapheme_capacity_bytes = 128, - .string_capacity_bytes = 256, - }; - - const fixture = - "\x50\x00\x18\x00\x02\x00\x02\x00" ++ - "\x10\x00\x00\x02\x80\x00\x00\x00" ++ - "\x00\x01\x00\x00" ++ - "\x01\x2a\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x00\x00\x00" ++ - "\x00\x00\x00\x00\x02\xaa\xbb\xcc" ++ - "\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"; - - var source: std.Io.Reader = .fixed(fixture); + var source: std.Io.Reader = .fixed(writer.buffered()); var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - var decoded = try decode(&limited.interface); + var decoded = try decode(&limited.interface, std.testing.allocator); defer decoded.deinit(); 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); + const decoded_style_a = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 1), decoded_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); + }).eql(decoded_style_a.value_ptr.*)); + const decoded_style_b = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 2), decoded_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.*)); + .bg_color = .{ .palette = 42 }, + }).eql(decoded_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( - @as(u32, 0x01020304), - hyperlink_a.id.implicit, - ); - 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); + const decoded_link_a = hyperlink_it.next().?; + try std.testing.expectEqual(@as(TerminalHyperlinkId, 1), decoded_link_a.id); + const decoded_hyperlink_a = pageHyperlink(&decoded, decoded_link_a.value_ptr); + try std.testing.expectEqualStrings("a", decoded_hyperlink_a.id.explicit); + try std.testing.expectEqualStrings("alpha", decoded_hyperlink_a.uri); + const decoded_link_b = hyperlink_it.next().?; + try std.testing.expectEqual(@as(TerminalHyperlinkId, 2), decoded_link_b.id); + const decoded_hyperlink_b = pageHyperlink(&decoded, decoded_link_b.value_ptr); + try std.testing.expectEqual(@as(u32, 0x01020304), decoded_hyperlink_b.id.implicit); + try std.testing.expectEqualStrings("beta", decoded_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()); + const decoded_first = decoded.getRowAndCell(0, 0); + try std.testing.expectEqual(@as(u21, 'A'), decoded_first.cell.codepoint()); + try std.testing.expectEqual(TerminalCell.Wide.wide, decoded_first.cell.wide); + try std.testing.expect(decoded_first.cell.protected); + try std.testing.expectEqual( + TerminalCell.SemanticContent.prompt, + decoded_first.cell.semantic_content, + ); + try std.testing.expectEqual(@as(TerminalStyleId, 1), decoded_first.cell.style_id); + try std.testing.expectEqual( + @as(TerminalHyperlinkId, 1), + decoded.lookupHyperlink(decoded_first.cell).?, + ); + + const decoded_second = decoded.getRowAndCell(1, 0); + try std.testing.expectEqual(TerminalCell.Wide.spacer_tail, decoded_second.cell.wide); + try std.testing.expectEqual(@as(TerminalStyleId, 2), decoded_second.cell.style_id); + try std.testing.expectEqual( + @as(TerminalHyperlinkId, 2), + decoded.lookupHyperlink(decoded_second.cell).?, + ); + + const decoded_grapheme = decoded.getRowAndCell(0, 1); + try std.testing.expectEqualSlices( + u21, + &.{ 0x0301, 0x0302 }, + decoded.lookupGrapheme(decoded_grapheme.cell).?, + ); + + const decoded_rgb = decoded.getRowAndCell(1, 1); + try std.testing.expectEqual(TerminalCell.ContentTag.bg_color_rgb, decoded_rgb.cell.content_tag); + try std.testing.expectEqual( + TerminalCell.RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + decoded_rgb.cell.content.color_rgb, + ); + + const decoded_spacer_head = decoded.getRowAndCell(2, 1); + try std.testing.expectEqual( + TerminalCell.Wide.spacer_head, + decoded_spacer_head.cell.wide, + ); + try std.testing.expect(decoded_spacer_head.row.wrap); + try std.testing.expect(decoded_spacer_head.row.wrap_continuation); + try std.testing.expectEqual( + TerminalRow.SemanticPrompt.prompt_continuation, + decoded_spacer_head.row.semantic_prompt, + ); + + var reencoded: [512]u8 = undefined; + var rewriter: std.Io.Writer = .fixed(&reencoded); + try encode(&decoded, &rewriter); + try std.testing.expect(!std.mem.eql( + u8, + writer.buffered(), + rewriter.buffered(), + )); + + var reencoded_reader: std.Io.Reader = .fixed(rewriter.buffered()); + var decoded_again = try decode( + &reencoded_reader, + std.testing.allocator, + ); + defer decoded_again.deinit(); + try decoded_again.verifyIntegrity(std.testing.allocator); + + var reencoded_again: [512]u8 = undefined; + var rewriter_again: std.Io.Writer = .fixed(&reencoded_again); + try encode(&decoded_again, &rewriter_again); + try std.testing.expectEqualStrings( + rewriter.buffered(), + rewriter_again.buffered(), + ); +} + +test "decode sparse page rejects every truncation" { + for (0..test_page_fixture.len) |len| { + var reader: std.Io.Reader = .fixed(test_page_fixture[0..len]); + try std.testing.expectError( + error.EndOfStream, + decode(&reader, std.testing.allocator), + ); + } +} + +test "decode accepts unordered sparse style IDs and rejects zero" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 2, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var descending: [ + Header.len + + 2 * (2 + style.len) + + 1 + + grid.CellHeader.len + ]u8 = undefined; + var descending_writer: std.Io.Writer = .fixed(&descending); + try header.encode(&descending_writer); + try io.writeInt(&descending_writer, TerminalStyleId, 3); + try style.encode(.{ .flags = .{ .bold = true } }, &descending_writer); + try io.writeInt(&descending_writer, TerminalStyleId, 2); + try style.encode(.{ .flags = .{ .italic = true } }, &descending_writer); + try descending_writer.writeByte(0); + try grid.CellHeader.encode(.{}, &descending_writer); + + var descending_reader: std.Io.Reader = .fixed( + descending_writer.buffered(), + ); + var decoded_descending = try decode( + &descending_reader, + std.testing.allocator, + ); + defer decoded_descending.deinit(); + try std.testing.expectEqual(@as(usize, 2), decoded_descending.styles.count()); + + const one_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var zero: [Header.len + 2 + style.len]u8 = undefined; + var zero_writer: std.Io.Writer = .fixed(&zero); + try one_header.encode(&zero_writer); + try io.writeInt(&zero_writer, TerminalStyleId, 0); + try style.encode(.{ .flags = .{ .bold = true } }, &zero_writer); + + var zero_reader: std.Io.Reader = .fixed(zero_writer.buffered()); + try std.testing.expectError( + error.InvalidStyleId, + decode(&zero_reader, std.testing.allocator), + ); +} + +test "decode accepts unordered sparse hyperlink IDs" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 2, + .style_capacity = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + const first: TerminalHyperlink = .{ + .id = .{ .implicit = 1 }, + .uri = "", + }; + const second: TerminalHyperlink = .{ + .id = .{ .implicit = 2 }, + .uri = "", + }; + + var encoded: [ + Header.len + + 2 * 11 + + 1 + + grid.CellHeader.len + ]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try io.writeInt(&writer, TerminalHyperlinkId, 3); + try hyperlink.encode(first, &writer); + try io.writeInt(&writer, TerminalHyperlinkId, 2); + try hyperlink.encode(second, &writer); + try writer.writeByte(0); + try grid.CellHeader.encode(.{}, &writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var decoded = try decode( + &reader, + std.testing.allocator, + ); + defer decoded.deinit(); + try std.testing.expectEqual( + @as(usize, 2), + decoded.hyperlink_set.count(), + ); +} + +test "decode defaults missing sparse cell references" { + const style_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var style_encoded: [Header.len + 1 + 16]u8 = undefined; + var style_writer: std.Io.Writer = .fixed(&style_encoded); + try style_header.encode(&style_writer); + try style_writer.writeByte(0); + try style_writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&style_writer, TerminalStyleId, 1); + try io.writeInt(&style_writer, TerminalHyperlinkId, 0); + try io.writeInt(&style_writer, u32, 0); + try io.writeInt(&style_writer, u32, 0); + + var style_reader: std.Io.Reader = .fixed(style_writer.buffered()); + var style_page = try decode( + &style_reader, + std.testing.allocator, + ); + defer style_page.deinit(); + try std.testing.expectEqual( + @as(TerminalStyleId, 0), + style_page.getRowAndCell(0, 0).cell.style_id, + ); + + const hyperlink_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var hyperlink_encoded: [Header.len + 1 + 16]u8 = undefined; + var hyperlink_writer: std.Io.Writer = .fixed(&hyperlink_encoded); + try hyperlink_header.encode(&hyperlink_writer); + try hyperlink_writer.writeByte(0); + try hyperlink_writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&hyperlink_writer, TerminalStyleId, 0); + try io.writeInt(&hyperlink_writer, TerminalHyperlinkId, 1); + try io.writeInt(&hyperlink_writer, u32, 0); + try io.writeInt(&hyperlink_writer, u32, 0); + + var hyperlink_reader: std.Io.Reader = .fixed( + hyperlink_writer.buffered(), + ); + var hyperlink_page = try decode( + &hyperlink_reader, + std.testing.allocator, + ); + defer hyperlink_page.deinit(); + const hyperlink_cell = hyperlink_page.getRowAndCell(0, 0).cell; + try std.testing.expect(!hyperlink_cell.hyperlink); + try std.testing.expectEqual( + null, + hyperlink_page.lookupHyperlink(hyperlink_cell), + ); +} + +test "decode rejects undefined row and cell values" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var invalid_row: [Header.len + 1]u8 = undefined; + var row_writer: std.Io.Writer = .fixed(&invalid_row); + try header.encode(&row_writer); + try row_writer.writeByte(0x10); + + var row_reader: std.Io.Reader = .fixed(row_writer.buffered()); + try std.testing.expectError( + error.InvalidRow, + decode(&row_reader, std.testing.allocator), + ); + + var invalid_cell: [Header.len + 2]u8 = undefined; + var cell_writer: std.Io.Writer = .fixed(&invalid_cell); + try header.encode(&cell_writer); + try cell_writer.writeByte(0); + try cell_writer.writeByte(3); + + var cell_reader: std.Io.Reader = .fixed(cell_writer.buffered()); + try std.testing.expectError( + error.InvalidCell, + decode(&cell_reader, std.testing.allocator), + ); + + var invalid_codepoint: [Header.len + 1 + 16]u8 = undefined; + var codepoint_writer: std.Io.Writer = .fixed(&invalid_codepoint); + try header.encode(&codepoint_writer); + try codepoint_writer.writeByte(0); + try codepoint_writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&codepoint_writer, TerminalStyleId, 0); + try io.writeInt(&codepoint_writer, TerminalHyperlinkId, 0); + try io.writeInt(&codepoint_writer, u32, 0xD800); + try io.writeInt(&codepoint_writer, u32, 0); + + var codepoint_reader: std.Io.Reader = .fixed( + codepoint_writer.buffered(), + ); + try std.testing.expectError( + error.InvalidCodepoint, + decode(&codepoint_reader, std.testing.allocator), + ); + + var invalid_color: [Header.len + 1 + 16]u8 = undefined; + var color_writer: std.Io.Writer = .fixed(&invalid_color); + try header.encode(&color_writer); + try color_writer.writeByte(0); + try color_writer.writeAll(&.{ 1, 0, 0, 0 }); + try io.writeInt(&color_writer, TerminalStyleId, 0); + try io.writeInt(&color_writer, TerminalHyperlinkId, 0); + try io.writeInt(&color_writer, u32, 0x100); + try io.writeInt(&color_writer, u32, 0); + + var color_reader: std.Io.Reader = .fixed(color_writer.buffered()); + try std.testing.expectError( + error.InvalidColor, + decode(&color_reader, std.testing.allocator), + ); } test "decode validates dimensions and native table capacities" { @@ -623,7 +1047,10 @@ test "decode validates dimensions and native table capacities" { try case.header.encode(&writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError(case.expected, decode(&reader)); + try std.testing.expectError( + case.expected, + decode(&reader, std.testing.allocator), + ); } } @@ -642,14 +1069,19 @@ test "decode rejects duplicate and default style entries" { .flags = .{ .bold = true }, }; - var encoded: [Header.len + 2 * style.len]u8 = undefined; + var encoded: [Header.len + 2 * (2 + style.len)]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); + try io.writeInt(&writer, TerminalStyleId, 1); try style.encode(duplicate_style, &writer); + try io.writeInt(&writer, TerminalStyleId, 3); try style.encode(duplicate_style, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError(error.DuplicateStyle, decode(&reader)); + try std.testing.expectError( + error.DuplicateStyle, + decode(&reader, std.testing.allocator), + ); const default_header: Header = .{ .columns = 80, @@ -661,13 +1093,17 @@ test "decode rejects duplicate and default style entries" { .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - var default_encoded: [Header.len + style.len]u8 = undefined; + var default_encoded: [Header.len + 2 + style.len]u8 = undefined; var default_writer: std.Io.Writer = .fixed(&default_encoded); try default_header.encode(&default_writer); + try io.writeInt(&default_writer, TerminalStyleId, 1); try style.encode(.{}, &default_writer); var default_reader: std.Io.Reader = .fixed(default_writer.buffered()); - try std.testing.expectError(error.DefaultStyle, decode(&default_reader)); + try std.testing.expectError( + error.DefaultStyle, + decode(&default_reader, std.testing.allocator), + ); } test "decode rejects duplicate hyperlinks with empty strings" { @@ -686,14 +1122,19 @@ test "decode rejects duplicate hyperlinks with empty strings" { .uri = "", }; - var encoded: [Header.len + 18]u8 = undefined; + var encoded: [Header.len + 22]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); + try io.writeInt(&writer, TerminalHyperlinkId, 1); try hyperlink.encode(duplicate, &writer); + try io.writeInt(&writer, TerminalHyperlinkId, 3); try hyperlink.encode(duplicate, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError(error.DuplicateHyperlink, decode(&reader)); + try std.testing.expectError( + error.DuplicateHyperlink, + decode(&reader, std.testing.allocator), + ); } test "decode reads hyperlink strings into page storage" { @@ -716,14 +1157,15 @@ test "decode reads hyperlink strings into page storage" { .string_capacity_bytes = 0, }; - var encoded: [Header.len + 14]u8 = undefined; + var encoded: [Header.len + 16]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); + try io.writeInt(&writer, TerminalHyperlinkId, 1); try hyperlink.encode(link, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( error.InvalidStringCapacity, - decode(&reader), + decode(&reader, std.testing.allocator), ); }