terminal/snapshot: hyperlink and style encoding

This commit is contained in:
Mitchell Hashimoto
2026-07-22 13:48:10 -07:00
parent fdf8dfd7b1
commit b4fd26f0d9
2 changed files with 712 additions and 0 deletions

View File

@@ -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),
);
}
}
}

View File

@@ -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));
}
}