From b4fd26f0d934809f12333f1bf81c38f7ab6beeec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Jul 2026 13:48:10 -0700 Subject: [PATCH] terminal/snapshot: hyperlink and style encoding --- src/terminal/snapshot/hyperlink.zig | 354 +++++++++++++++++++++++++++ src/terminal/snapshot/style.zig | 358 ++++++++++++++++++++++++++++ 2 files changed, 712 insertions(+) create mode 100644 src/terminal/snapshot/hyperlink.zig create mode 100644 src/terminal/snapshot/style.zig diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig new file mode 100644 index 000000000..966bed247 --- /dev/null +++ b/src/terminal/snapshot/hyperlink.zig @@ -0,0 +1,354 @@ +//! Snapshot hyperlink entry encoding. +//! +//! Each entry contains a URI and either an implicit numeric ID or an explicit +//! byte-string ID. Implicit IDs are generated by the terminal when the source +//! hyperlink has no explicit ID. A record can use these entries to build a +//! hyperlink table and assign indexes according to that record's format. +//! Indexing and ordering are properties of the containing record rather than +//! this codec. +//! +//! IDs and URIs are arbitrary byte strings. Their lengths are retained on the +//! wire; they are not NUL-terminated and need not contain UTF-8. +//! +//! All integers are unsigned and little-endian. +//! +//! ## Implicit ID +//! +//! | Offset | Size | Field | +//! | -----: | --------: | :----------------- | +//! | 0 | 1 | Kind, `1` | +//! | 1 | 4 | Implicit ID (`u32`) | +//! | 5 | 4 | URI length (`u32`) | +//! | 9 | `uri_len` | URI bytes | +//! +//! ## Explicit ID +//! +//! | Offset | Size | Field | +//! | -----------: | --------: | :------------------------- | +//! | 0 | 1 | Kind, `2` | +//! | 1 | 4 | Explicit ID length (`u32`) | +//! | 5 | `id_len` | Explicit ID bytes | +//! | `5 + id_len` | 4 | URI length (`u32`) | +//! | `9 + id_len` | `uri_len` | URI bytes | +//! +//! 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. + +const std = @import("std"); +const io = @import("io.zig"); +const terminal_hyperlink = @import("../hyperlink.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; + +/// Errors possible while decoding one hyperlink entry. +pub const DecodeError = std.Io.Reader.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; + }; + 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)); + try io.writeInt(writer, u32, id); + try io.writeInt(writer, u32, @intCast(value.uri.len)); + try writer.writeAll(value.uri); + }, + .explicit => |id| { + try writer.writeByte(@intFromEnum(Kind.explicit)); + try io.writeInt(writer, u32, @intCast(id.len)); + try writer.writeAll(id); + try io.writeInt(writer, u32, @intCast(value.uri.len)); + try writer.writeAll(value.uri); + }, + } +} + +/// Decode one hyperlink entry into caller-owned string storage. +/// +/// 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. +pub fn decode( + reader: *std.Io.Reader, + strings: []u8, +) DecodeError!Decoded { + const kind_raw = try reader.takeByte(); + const kind = std.enums.fromInt(Kind, kind_raw) orelse { + return error.InvalidKind; + }; + + return switch (kind) { + .implicit => decodeImplicit(reader, strings), + .explicit => decodeExplicit(reader, strings), + }; +} + +fn decodeImplicit( + 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; + + const uri = strings[0..uri_len]; + try reader.readSliceAll(uri); + return .{ + .value = .{ + .id = .{ .implicit = id }, + .uri = uri, + }, + .string_bytes = uri.len, + }; +} + +fn decodeExplicit( + 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; + + const id = strings[0..id_len]; + try reader.readSliceAll(id); + + 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, + }; +} + +test "golden implicit encoding" { + const value: terminal_hyperlink.Hyperlink = .{ + .id = .{ .implicit = 0x01020304 }, + .uri = "uri", + }; + + var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try std.testing.expectEqualStrings( + "\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" { + const value: terminal_hyperlink.Hyperlink = .{ + .id = .{ .explicit = "id" }, + .uri = "uri", + }; + + var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try std.testing.expectEqualStrings( + "\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" { + const values = .{ + terminal_hyperlink.Hyperlink{ + .id = .{ .implicit = 0 }, + .uri = "", + }, + terminal_hyperlink.Hyperlink{ + .id = .{ .explicit = "" }, + .uri = "", + }, + }; + + inline for (values) |value| { + var encoded: [9]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + 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); + switch (value.id) { + .implicit => |id| try std.testing.expectEqual( + id, + decoded.value.id.implicit, + ), + .explicit => |id| try std.testing.expectEqualStrings( + id, + decoded.value.id.explicit, + ), + } + } +} + +test "decode with a one-byte reader buffer" { + const fixture = + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri"; + var source: std.Io.Reader = .fixed(fixture); + 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); + try std.testing.expectEqual( + @as(u32, 0x01020304), + implicit.value.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); +} + +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), + ); + } +} + +test "reject insufficient string storage" { + { + var reader: std.Io.Reader = .fixed( + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + ); + var strings: [2]u8 = undefined; + try std.testing.expectError( + error.BufferTooSmall, + decode(&reader, &strings), + ); + } + + { + var reader: std.Io.Reader = .fixed( + "\x02\x03\x00\x00\x00id!\x03\x00\x00\x00uri", + ); + 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), + ); + } +} + +test "reject every truncation" { + const fixtures = .{ + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + }; + + 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), + ); + } + } +} diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig new file mode 100644 index 000000000..87bc3f5be --- /dev/null +++ b/src/terminal/snapshot/style.zig @@ -0,0 +1,358 @@ +//! Snapshot style entry encoding. +//! +//! Each entry describes one terminal style. A record can use these entries to +//! build a style table and assign indexes according to that record's format. +//! Indexing, ordering, etc. are properties of the containing record. +//! +//! Styles are encoded field by field from the terminal's native `Style` type. +//! The native packed representation is deliberately not part of the snapshot +//! format. This gives flexibility for changing one side or the other. +//! +//! All integers are unsigned and little-endian. +//! +//! | Offset | Size | Field | +//! | -----: | ---: | :------------------------ | +//! | 0 | 4 | Foreground color | +//! | 4 | 4 | Background color | +//! | 8 | 4 | Underline color | +//! | 12 | 2 | Style flags (`u16`) | +//! | 14 | 2 | Reserved, must be zero | +//! +//! The trailing reserved field is explicit wire padding that rounds each +//! style entry from 14 bytes to 16 bytes. This makes entry offsets and byte +//! counts straightforward. +//! +//! Each color begins with a one-byte kind followed by three data bytes: +//! +//! | Kind | Meaning | Data bytes | +//! | ---: | :------ | :--------------------------------- | +//! | 0 | None | All zero | +//! | 1 | Palette | Palette index, then two zero bytes | +//! | 2 | RGB | Red, green, blue | +//! +//! Style flag bits 0 through 7 are bold, italic, faint, blink, inverse, +//! invisible, strikethrough, and overline. Bits 8 through 10 contain the +//! underline kind. Bits 11 through 15 must be zero. +//! +//! | Underline | Meaning | +//! | --------: | :------ | +//! | 0 | None | +//! | 1 | Single | +//! | 2 | Double | +//! | 3 | Curly | +//! | 4 | Dotted | +//! | 5 | Dashed | +//! +//! Underline values 6 and 7 are invalid in snapshot version 0. + +const std = @import("std"); +const io = @import("io.zig"); +const sgr = @import("../sgr.zig"); +const terminal_style = @import("../style.zig"); + +/// 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); +} + +const Flags = packed struct(u16) { + bold: bool = false, + italic: bool = false, + faint: bool = false, + blink: bool = false, + inverse: bool = false, + invisible: bool = false, + strikethrough: bool = false, + overline: bool = false, + underline: u3 = 0, + reserved: u5 = 0, +}; + +const ColorKind = enum(u8) { + none = 0, + palette = 1, + rgb = 2, +}; + +/// Errors possible while decoding one style entry. +pub const DecodeError = std.Io.Reader.Error || error{ + /// A color kind is not defined by snapshot version 0. + InvalidColorKind, + + /// The encoded underline kind is not defined by snapshot version 0. + InvalidUnderline, + + /// One or more reserved style flag bits are set. + InvalidFlags, + + /// The trailing reserved field is not zero. + InvalidReserved, +}; + +/// Encode one terminal style as a fixed-size snapshot style entry. +pub fn encode( + value: terminal_style.Style, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + try encodeColor(value.fg_color, writer); + try encodeColor(value.bg_color, writer); + try encodeColor(value.underline_color, writer); + + const flags: Flags = .{ + .bold = value.flags.bold, + .italic = value.flags.italic, + .faint = value.flags.faint, + .blink = value.flags.blink, + .inverse = value.flags.inverse, + .invisible = value.flags.invisible, + .strikethrough = value.flags.strikethrough, + .overline = value.flags.overline, + .underline = @intFromEnum(value.flags.underline), + }; + try io.writeInt(writer, u16, @bitCast(flags)); + try io.writeInt(writer, u16, 0); +} + +/// Decode and validate one fixed-size snapshot style entry. +pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style { + const fg_color = try decodeColor(reader); + const bg_color = try decodeColor(reader); + const underline_color = try decodeColor(reader); + + const flags: Flags = @bitCast(try io.readInt(reader, u16)); + if (flags.reserved != 0) return error.InvalidFlags; + + const underline = std.enums.fromInt( + sgr.Attribute.Underline, + flags.underline, + ) orelse return error.InvalidUnderline; + + const reserved = try io.readInt(reader, u16); + if (reserved != 0) return error.InvalidReserved; + + return .{ + .fg_color = fg_color, + .bg_color = bg_color, + .underline_color = underline_color, + .flags = .{ + .bold = flags.bold, + .italic = flags.italic, + .faint = flags.faint, + .blink = flags.blink, + .inverse = flags.inverse, + .invisible = flags.invisible, + .strikethrough = flags.strikethrough, + .overline = flags.overline, + .underline = underline, + }, + }; +} + +fn encodeColor( + value: terminal_style.Style.Color, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + var encoded: [4]u8 = @splat(0); + switch (value) { + .none => encoded[0] = @intFromEnum(ColorKind.none), + .palette => |index| { + encoded[0] = @intFromEnum(ColorKind.palette); + encoded[1] = index; + }, + .rgb => |rgb| { + encoded[0] = @intFromEnum(ColorKind.rgb); + encoded[1] = rgb.r; + encoded[2] = rgb.g; + encoded[3] = rgb.b; + }, + } + try writer.writeAll(&encoded); +} + +fn decodeColor( + reader: *std.Io.Reader, +) DecodeError!terminal_style.Style.Color { + // Colors are always 4 bytes + var encoded: [4]u8 = undefined; + try reader.readSliceAll(&encoded); + + // Kind must be something we know about. + const kind = std.enums.fromInt(ColorKind, encoded[0]) orelse { + return error.InvalidColorKind; + }; + + return switch (kind) { + .none => .none, + .palette => .{ .palette = encoded[1] }, + .rgb => .{ .rgb = .{ + .r = encoded[1], + .g = encoded[2], + .b = encoded[3], + } }, + }; +} + +fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + encode(.{}, &writer) catch unreachable; + return writer.end; + } +} + +test "golden encoding" { + const value: terminal_style.Style = .{ + .fg_color = .none, + .bg_color = .{ .palette = 0x7f }, + .underline_color = .{ .rgb = .{ + .r = 0x12, + .g = 0x34, + .b = 0x56, + } }, + .flags = .{ + .bold = true, + .italic = true, + .faint = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + }, + }; + + var buf: [len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try std.testing.expectEqualStrings( + "\x00\x00\x00\x00" ++ + "\x01\x7f\x00\x00" ++ + "\x02\x12\x34\x56" ++ + "\xff\x03\x00\x00", + writer.buffered(), + ); +} + +test "flag bit layout" { + const cases = .{ + .{ Flags{ .bold = true }, @as(u16, 1 << 0) }, + .{ Flags{ .italic = true }, @as(u16, 1 << 1) }, + .{ Flags{ .faint = true }, @as(u16, 1 << 2) }, + .{ Flags{ .blink = true }, @as(u16, 1 << 3) }, + .{ Flags{ .inverse = true }, @as(u16, 1 << 4) }, + .{ Flags{ .invisible = true }, @as(u16, 1 << 5) }, + .{ Flags{ .strikethrough = true }, @as(u16, 1 << 6) }, + .{ Flags{ .overline = true }, @as(u16, 1 << 7) }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.single) }, + @as(u16, 1 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.double) }, + @as(u16, 2 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.curly) }, + @as(u16, 3 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dotted) }, + @as(u16, 4 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dashed) }, + @as(u16, 5 << 8), + }, + .{ Flags{ .reserved = 1 }, @as(u16, 1 << 11) }, + }; + + inline for (cases) |case| { + try std.testing.expectEqual(case[1], @as(u16, @bitCast(case[0]))); + } +} + +test "decode with a one-byte reader buffer" { + const fixture = + "\x00\x00\x00\x00" ++ + "\x01\x7f\x00\x00" ++ + "\x02\x12\x34\x56" ++ + "\xff\x03\x00\x00"; + var source: std.Io.Reader = .fixed(fixture); + var buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buf); + + const expected: terminal_style.Style = .{ + .fg_color = .none, + .bg_color = .{ .palette = 0x7f }, + .underline_color = .{ .rgb = .{ + .r = 0x12, + .g = 0x34, + .b = 0x56, + } }, + .flags = .{ + .bold = true, + .italic = true, + .faint = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + }, + }; + const actual = try decode(&limited.interface); + try std.testing.expect(expected.eql(actual)); +} + +test "reject invalid color kinds" { + inline for (.{ 0, 4, 8 }) |offset| { + var invalid_kind: [len]u8 = @splat(0); + invalid_kind[offset] = 3; + var reader: std.Io.Reader = .fixed(&invalid_kind); + try std.testing.expectError(error.InvalidColorKind, decode(&reader)); + } +} + +test "reject invalid flags and reserved field" { + inline for (.{ 6, 7 }) |underline| { + var invalid_underline: [len]u8 = @splat(0); + std.mem.writeInt( + u16, + invalid_underline[12..14], + underline << 8, + .little, + ); + var reader: std.Io.Reader = .fixed(&invalid_underline); + try std.testing.expectError(error.InvalidUnderline, decode(&reader)); + } + + var invalid_flags: [len]u8 = @splat(0); + std.mem.writeInt(u16, invalid_flags[12..14], 1 << 11, .little); + var flags_reader: std.Io.Reader = .fixed(&invalid_flags); + try std.testing.expectError(error.InvalidFlags, decode(&flags_reader)); + + var invalid_reserved: [len]u8 = @splat(0); + std.mem.writeInt(u16, invalid_reserved[14..16], 1, .little); + var reserved_reader: std.Io.Reader = .fixed(&invalid_reserved); + try std.testing.expectError( + error.InvalidReserved, + decode(&reserved_reader), + ); +} + +test "reject every truncation" { + const fixture = [_]u8{0} ** len; + for (0..len) |fixture_len| { + var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); + try std.testing.expectError(error.EndOfStream, decode(&reader)); + } +}