Files
ghostty/src/terminal/snapshot/hyperlink.zig
2026-07-28 07:29:34 -07:00

361 lines
11 KiB
Zig

//! 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.
//!
//! 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 encoding one hyperlink entry.
pub const EncodeError = std.Io.Writer.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 1.
InvalidKind,
};
/// 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 1.
InvalidKind,
/// The hyperlink value already exists in the page.
DuplicateHyperlink,
};
/// Encode one hyperlink entry.
pub fn encode(
value: terminal_hyperlink.Hyperlink,
writer: *std.Io.Writer,
) EncodeError!void {
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 allocator-owned hyperlink entry.
///
/// The caller owns the returned value and must call `Hyperlink.deinit`.
pub fn decode(
reader: *std.Io.Reader,
alloc: Allocator,
) DecodeError!terminal_hyperlink.Hyperlink {
const kind_raw = try reader.takeByte();
const kind = std.enums.fromInt(Kind, kind_raw) orelse
return error.InvalidKind;
return switch (kind) {
.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,
};
},
};
}
/// 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 ID assigned by the destination page.
pub fn decodePage(
page: *terminal_page.Page,
reader: *std.Io.Reader,
) DecodePageError!terminal_hyperlink.Id {
const kind_raw = try reader.takeByte();
const kind = std.enums.fromInt(Kind, kind_raw) orelse
return error.InvalidKind;
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,
};
},
.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);
if (page.hyperlink_set.lookupContext(
page.memory,
entry,
.{ .page = page },
) != null) {
return error.DuplicateHyperlink;
}
return page.hyperlink_set.addContext(
page.memory,
entry,
.{ .page = page },
) catch |err| switch (err) {
error.OutOfMemory => error.SetOutOfMemory,
error.NeedsRehash => error.SetNeedsRehash,
};
}
fn decodePageString(
page: *terminal_page.Page,
reader: *std.Io.Reader,
len: usize,
) (std.Io.Reader.Error || error{StringsOutOfMemory})!terminal_size.Offset(u8).Slice {
if (len == 0) return .{};
// 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);
return .{
.len = value.len,
.offset = terminal_size.getOffset(
u8,
page.memory,
&value[0],
),
};
}
test "golden implicit encoding" {
const value: terminal_hyperlink.Hyperlink = .{
.id = .{ .implicit = 0x01020304 },
.uri = "uri",
};
var buf: [128]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(),
);
}
test "golden explicit encoding" {
const value: terminal_hyperlink.Hyperlink = .{
.id = .{ .explicit = "id" },
.uri = "uri",
};
var buf: [128]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(),
);
}
test "empty strings round trip" {
const values: [2]terminal_hyperlink.Hyperlink = .{
.{
.id = .{ .implicit = 0 },
.uri = "",
},
.{
.id = .{ .explicit = "" },
.uri = "",
},
};
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());
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.id.implicit,
),
.explicit => |id| try std.testing.expectEqualStrings(
id,
decoded.id.explicit,
),
}
}
}
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);
try std.testing.expectError(
error.InvalidKind,
decode(&reader, std.testing.allocator),
);
}
}
test "decode allocation failure" {
{
var reader: std.Io.Reader = .fixed(
"\x01\x04\x03\x02\x01\x03\x00\x00\x00uri",
);
var failing = std.testing.FailingAllocator.init(
std.testing.allocator,
.{ .fail_index = 0 },
);
try std.testing.expectError(
error.OutOfMemory,
decode(&reader, failing.allocator()),
);
}
for (0..2) |fail_index| {
var failing = std.testing.FailingAllocator.init(
std.testing.allocator,
.{ .fail_index = fail_index },
);
var reader: std.Io.Reader = .fixed(
"\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri",
);
try std.testing.expectError(
error.OutOfMemory,
decode(&reader, failing.allocator()),
);
}
}
test "reject every truncation" {
const fixtures: [2][]const u8 = .{
"\x01\x04\x03\x02\x01\x03\x00\x00\x00uri",
"\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri",
};
for (fixtures) |fixture| {
for (0..fixture.len) |fixture_len| {
var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]);
try std.testing.expectError(
error.EndOfStream,
decode(&reader, std.testing.allocator),
);
}
}
}