terminal/snapshot: decode directly into pages

This commit is contained in:
Mitchell Hashimoto
2026-07-24 14:34:46 -07:00
parent d44baa9147
commit 2fc238ed01
3 changed files with 462 additions and 413 deletions

View File

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

View File

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

View File

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