terminal: redo trailing state capture in OSC parser (#11873)

Trailing state capture now is encapsulated in a struct `Capture` and all
parsers access the data via `p.capture.trailing()` rather than directly
from the writer.

This is primarily to prep for the OSC parser to be able to capture the
entire sequence (not just the trailing part) so we can setup libghostty
for fallback handlers so libghostty implementers can have custom OSC
behaviors.

But, it has the benefit of making our OSC parser much cleaner too.

I'm doing some benchmarks now...
This commit is contained in:
Mitchell Hashimoto
2026-03-26 14:04:49 -07:00
committed by GitHub
16 changed files with 153 additions and 98 deletions

View File

@@ -301,12 +301,10 @@ pub const Parser = struct {
/// Buffer for temporary storage of OSC data
buffer: [MAX_BUF]u8,
/// Fixed writer for accumulating OSC data
fixed: ?std.Io.Writer,
/// Allocating writer for accumulating OSC data
allocating: ?std.Io.Writer.Allocating,
/// Pointer to the active writer for accumulating OSC data
writer: ?*std.Io.Writer,
/// Capture state. If this is set then we're actively capturing the
/// bytes coming into the parser.
capture: ?Capture,
/// The command that is the result of parsing.
command: Command,
@@ -369,9 +367,7 @@ pub const Parser = struct {
var result: Parser = .{
.alloc = alloc,
.state = .start,
.fixed = null,
.allocating = null,
.writer = null,
.capture = null,
.command = .invalid,
// Keeping all our undefined values together so we can
@@ -394,8 +390,8 @@ pub const Parser = struct {
/// Reset the parser state.
pub fn reset(self: *Parser) void {
// If we set up an allocating writer, free up that memory.
if (self.allocating) |*allocating| allocating.deinit();
// If we're capturing, then stop it.
if (self.capture) |*cap| cap.deinit();
// Handle any cleanup that individual OSCs require.
switch (self.command) {
@@ -430,9 +426,7 @@ pub const Parser = struct {
}
self.state = .start;
self.fixed = null;
self.allocating = null;
self.writer = null;
self.capture = null;
self.command = .invalid;
if (std.valgrind.runningOnValgrind() > 0) {
@@ -451,31 +445,91 @@ pub const Parser = struct {
return false;
}
/// Set up a fixed Writer to collect the rest of the OSC data.
inline fn writeToFixed(self: *Parser) void {
self.fixed = .fixed(&self.buffer);
self.writer = &self.fixed.?;
}
const Capture = struct {
writer: *std.Io.Writer,
backing: Backing,
/// Set up an allocating Writer to collect the rest of the OSC data. If we
/// don't have an allocator or setting up the allocator fails, fall back to
/// writing to a fixed buffer and hope that it's big enough.
inline fn writeToAllocating(self: *Parser) void {
const alloc = self.alloc orelse {
// We don't have an allocator - fall back to a fixed buffer and hope
// that it's big enough.
self.writeToFixed();
return;
const Backing = union(enum) {
fixed: std.Io.Writer,
allocating: std.Io.Writer.Allocating,
};
self.allocating = std.Io.Writer.Allocating.initCapacity(alloc, 2048) catch {
// The allocator failed for some reason, fall back to a fixed buffer
// and hope that it's big enough.
self.writeToFixed();
return;
const Mode = enum {
fixed,
allocating,
};
self.writer = &self.allocating.?.writer;
pub inline fn fixed(new: *?Capture, buf: []u8) void {
new.* = .{
.backing = .{ .fixed = .fixed(buf) },
.writer = &new.*.?.backing.fixed,
};
}
pub inline fn allocating(
new: *?Capture,
alloc: Allocator,
) error{OutOfMemory}!void {
new.* = .{
.backing = .{ .allocating = try std.Io.Writer.Allocating.initCapacity(
alloc,
2048,
) },
.writer = &new.*.?.backing.allocating.writer,
};
}
pub fn deinit(self: *Capture) void {
switch (self.backing) {
.fixed => {},
.allocating => |*w| w.deinit(),
}
}
/// Return the captured trailing data. This is the data from the
/// point that trailing data capture was requested.
pub inline fn trailing(self: *Capture) []u8 {
return self.writer.buffered();
}
};
/// Begin capturing trailing data. All inputs to next from this point
/// forward will be captured into the `self.capture.writer` buffer
/// which may be backed by either a fixed size or allocating buffer
/// depending on mode.
///
/// Get the trailing data using `capture.trailing()`. Do not access
/// the writer directly.
inline fn captureTrailing(
self: *Parser,
comptime mode: Capture.Mode,
) void {
assert(self.capture == null);
switch (mode) {
.fixed => Capture.fixed(
&self.capture,
&self.buffer,
),
.allocating => {
const alloc = self.alloc orelse {
// We don't have an allocator - fall back to a fixed buffer and hope
// that it's big enough.
self.captureTrailing(.fixed);
return;
};
Capture.allocating(
&self.capture,
alloc,
) catch {
// The allocator failed for some reason, fall back to a fixed buffer
// and hope that it's big enough.
self.captureTrailing(.fixed);
return;
};
},
}
}
/// Consume the next character c and advance the parser state.
@@ -486,8 +540,8 @@ pub const Parser = struct {
// If a writer has been initialized, we just accumulate the rest of the
// OSC sequence in the writer's buffer and skip the state machine.
if (self.writer) |writer| {
writer.writeByte(c) catch |err| switch (err) {
if (self.capture) |*cap| {
cap.writer.writeByte(c) catch |err| switch (err) {
// We have overflowed our buffer or had some other error, set the
// state to invalid so that we discard any further input.
error.WriteFailed => self.state = .invalid,
@@ -529,12 +583,12 @@ pub const Parser = struct {
},
.@"3008" => switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
else => self.state = .invalid,
},
.@"1" => switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
'0' => self.state = .@"10",
'1' => self.state = .@"11",
'2' => self.state = .@"12",
@@ -549,18 +603,18 @@ pub const Parser = struct {
},
.@"10" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
';' => if (self.ensureAllocator()) self.captureTrailing(.fixed),
'4' => self.state = .@"104",
else => self.state = .invalid,
},
.@"104" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
';' => if (self.ensureAllocator()) self.captureTrailing(.fixed),
else => self.state = .invalid,
},
.@"11" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
';' => if (self.ensureAllocator()) self.captureTrailing(.fixed),
'0' => self.state = .@"110",
'1' => self.state = .@"111",
'2' => self.state = .@"112",
@@ -594,25 +648,25 @@ pub const Parser = struct {
.@"118",
.@"119",
=> switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
';' => if (self.ensureAllocator()) self.captureTrailing(.fixed),
else => self.state = .invalid,
},
.@"13" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
';' => if (self.ensureAllocator()) self.captureTrailing(.fixed),
'3' => self.state = .@"133",
else => self.state = .invalid,
},
.@"2" => switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
'1' => self.state = .@"21",
'2' => self.state = .@"22",
else => self.state = .invalid,
},
.@"5" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
';' => if (self.ensureAllocator()) self.captureTrailing(.fixed),
'2' => self.state = .@"52",
'5' => self.state = .@"55",
else => self.state = .invalid,
@@ -626,7 +680,7 @@ pub const Parser = struct {
.@"52",
.@"66",
=> switch (c) {
';' => self.writeToAllocating(),
';' => self.captureTrailing(.allocating),
else => self.state = .invalid,
},
@@ -636,7 +690,7 @@ pub const Parser = struct {
},
.@"7" => switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
'7' => self.state = .@"77",
else => self.state = .invalid,
},
@@ -648,7 +702,7 @@ pub const Parser = struct {
.@"133",
=> switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
'7' => self.state = .@"1337",
else => self.state = .invalid,
},
@@ -660,13 +714,13 @@ pub const Parser = struct {
.@"1337",
=> switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
else => self.state = .invalid,
},
.@"5522",
=> switch (c) {
';' => self.writeToAllocating(),
';' => self.captureTrailing(.allocating),
else => self.state = .invalid,
},
@@ -676,7 +730,7 @@ pub const Parser = struct {
.@"8",
.@"9",
=> switch (c) {
';' => self.writeToFixed(),
';' => self.captureTrailing(.fixed),
else => self.state = .invalid,
},
}

View File

@@ -4,15 +4,15 @@ const Command = @import("../../osc.zig").Command;
/// Parse OSC 1
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
parser.command = .{
.change_window_icon = data[0 .. data.len - 1 :0],
};

View File

@@ -5,15 +5,15 @@ const Command = @import("../../osc.zig").Command;
/// Parse OSC 0 and OSC 2
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
parser.command = .{
.change_window_title = data[0 .. data.len - 1 :0],
};

View File

@@ -8,15 +8,15 @@ const Command = @import("../../osc.zig").Command;
/// Parse OSC 52
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
assert(parser.state == .@"52");
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
if (data.len == 1) {
parser.state = .invalid;
return null;

View File

@@ -50,8 +50,8 @@ pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
// If we've collected any extra data parse that, otherwise use an empty
// string.
const data = data: {
const writer = parser.writer orelse break :data "";
break :data writer.buffered();
const cap = if (parser.capture) |*c| c else break :data "";
break :data cap.trailing();
};
// Check and make sure that we're parsing the correct OSCs
const op: Operation = switch (parser.state) {

View File

@@ -201,11 +201,11 @@ pub const Field = enum {
/// start=<id>[;<field>=<value>]*
/// end=<id>[;<field>=<value>]*
pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
if (data.len == 0) {
parser.state = .invalid;
return null;

View File

@@ -7,15 +7,15 @@ const log = std.log.scoped(.osc_hyperlink);
/// Parse OSC 8 hyperlinks
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
const s = std.mem.indexOfScalar(u8, data, ';') orelse {
parser.state = .invalid;
return null;

View File

@@ -66,15 +66,15 @@ const map: Map = .initComptime(
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
assert(parser.state == .@"1337");
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
const key_str: [:0]u8, const value_: ?[:0]u8 = kv: {
const index = std.mem.indexOfScalar(u8, data, '=') orelse {

View File

@@ -152,12 +152,12 @@ fn parseIdentifier(str: []const u8) ?[]const u8 {
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"5522");
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
const metadata: []const u8, const payload: ?[]const u8 = result: {
const start = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };

View File

@@ -17,7 +17,7 @@ pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
@@ -28,7 +28,7 @@ pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
},
};
const list = &parser.command.kitty_color_protocol.list;
const data = writer.buffered();
const data = cap.trailing();
var kv_it = std.mem.splitScalar(u8, data, ';');
while (kv_it.next()) |kv| {
if (list.items.len >= @as(usize, kitty_color.Kind.max) * 2) {

View File

@@ -71,17 +71,17 @@ pub const OSC = struct {
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
assert(parser.state == .@"66");
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
// Write a NUL byte to ensure that `text` is NUL-terminated
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
const payload_start = std.mem.indexOfScalar(u8, data, ';') orelse {
log.warn("missing semicolon before payload", .{});

View File

@@ -8,15 +8,15 @@ const Command = @import("../../osc.zig").Command;
// Parse OSC 22
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
assert(parser.state == .@"22");
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
parser.command = .{
.mouse_shape = .{
.value = data[0 .. data.len - 1 :0],

View File

@@ -5,15 +5,16 @@ const Command = @import("../../osc.zig").Command;
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const writer = cap.writer;
// Check first to see if this is a ConEmu OSC
// https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
conemu: {
var data = writer.buffered();
var data = cap.trailing();
if (data.len == 0) break :conemu;
switch (data[0]) {
// Check for OSC 9;1 9;10 9;11 9;12
@@ -90,7 +91,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.conemu_comment = data[3 .. data.len - 1 :0],
};
@@ -112,7 +113,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.conemu_show_message_box = data[2 .. data.len - 1 :0],
};
@@ -132,7 +133,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.conemu_change_tab_title = .{
.value = data[2 .. data.len - 1 :0],
@@ -214,7 +215,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.conemu_guimacro = data[2 .. data.len - 1 :0],
};
@@ -228,7 +229,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.conemu_run_process = data[2 .. data.len - 1 :0],
};
@@ -242,7 +243,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.conemu_output_environment_variable = data[2 .. data.len - 1 :0],
};
@@ -256,7 +257,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
data = writer.buffered();
data = cap.trailing();
parser.command = .{
.report_pwd = .{
.value = data[2 .. data.len - 1 :0],
@@ -274,7 +275,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
parser.command = .{
.show_desktop_notification = .{
.title = "",

View File

@@ -5,15 +5,15 @@ const Command = @import("../../osc.zig").Command;
/// Parse OSC 7
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
parser.command = .{
.report_pwd = .{
.value = data[0 .. data.len - 1 :0],

View File

@@ -7,16 +7,16 @@ const log = std.log.scoped(.osc_rxvt_extension);
/// Parse OSC 777
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
// ensure that we are sentinel terminated
writer.writeByte(0) catch {
cap.writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
const k = std.mem.indexOfScalar(u8, data, ';') orelse {
parser.state = .invalid;
return null;

View File

@@ -298,11 +298,11 @@ pub const Redraw = enum(u2) {
/// Parse OSC 133, semantic prompts
pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand {
const writer = parser.writer orelse {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const data = cap.trailing();
if (data.len == 0) {
parser.state = .invalid;
return null;