aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/stream.zig
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/stream.zig
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/stream.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/stream.zig537
1 files changed, 524 insertions, 13 deletions
diff --git a/vendor/libghostty-vt/src/terminal/stream.zig b/vendor/libghostty-vt/src/terminal/stream.zig
index 9771334f..efb06ecd 100644
--- a/vendor/libghostty-vt/src/terminal/stream.zig
+++ b/vendor/libghostty-vt/src/terminal/stream.zig
@@ -33,6 +33,7 @@ const debug = false;
/// function for handling.
pub const Action = union(Key) {
print: Print,
+ print_slice: PrintSlice,
print_repeat: usize,
bell,
backspace,
@@ -110,6 +111,7 @@ pub const Action = union(Key) {
apc_start,
apc_end,
apc_put: u8,
+ apc_put_slice: ApcPutSlice,
end_hyperlink,
active_status_display: ansi.StatusDisplay,
decaln,
@@ -130,6 +132,7 @@ pub const Action = union(Key) {
lib.target,
&.{
"print",
+ "print_slice",
"print_repeat",
"bell",
"backspace",
@@ -207,6 +210,7 @@ pub const Action = union(Key) {
"apc_start",
"apc_end",
"apc_put",
+ "apc_put_slice",
"end_hyperlink",
"active_status_display",
"decaln",
@@ -252,6 +256,40 @@ pub const Action = union(Key) {
}
};
+ /// A run of printable codepoints. This is emitted instead of
+ /// individual print actions when the stream can decode multiple
+ /// printable codepoints at once, so handlers can process them in
+ /// batch with per-run rather than per-codepoint overhead (see
+ /// Terminal.printSlice). A naive handler can simply loop and
+ /// handle each codepoint like a print action.
+ ///
+ /// The slice is only valid for the duration of the handler call.
+ pub const PrintSlice = struct {
+ cps: []const u32,
+
+ pub const C = extern struct {
+ cps: [*]const u32,
+ len: usize,
+ };
+
+ pub fn cval(self: PrintSlice) PrintSlice.C {
+ return .{ .cps = self.cps.ptr, .len = self.cps.len };
+ }
+ };
+
+ pub const ApcPutSlice = struct {
+ bytes: []const u8,
+
+ pub const C = extern struct {
+ bytes: [*]const u8,
+ len: usize,
+ };
+
+ pub fn cval(self: ApcPutSlice) ApcPutSlice.C {
+ return .{ .bytes = self.bytes.ptr, .len = self.bytes.len };
+ }
+ };
+
pub const InvokeCharset = lib.Struct(lib.target, struct {
bank: charsets.ActiveSlot,
charset: charsets.Slots,
@@ -411,6 +449,11 @@ pub const Action = union(Key) {
/// about in its pursuit of implementing a terminal emulator or other
/// functionality.
///
+/// Note that printable text is delivered via `print_slice` actions
+/// (runs of codepoints) whenever the stream can decode multiple
+/// codepoints at once, and via `print` actions otherwise. Handlers
+/// that care about text must handle both.
+///
/// The Handler type must also have a `deinit` function.
///
/// The "comptime" key is on purpose (vs. a standard Zig tagged union)
@@ -521,12 +564,25 @@ pub fn Stream(comptime H: type) type {
// up to that point are just UTF-8.
while (self.parser.state == .ground and offset < input.len) {
const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf);
- for (cp_buf[0..res.decoded]) |cp| {
+ const cps = cp_buf[0..res.decoded];
+
+ // Hand runs of printable codepoints to the handler as
+ // print_slice actions so it can process them with
+ // per-run rather than per-codepoint overhead.
+ var i: usize = 0;
+ while (i < cps.len) {
+ const cp = cps[i];
if (cp <= 0xF) {
+ @branchHint(.unlikely);
self.execute(@intCast(cp));
- } else {
- self.print(@intCast(cp));
+ i += 1;
+ continue;
}
+
+ var end = i + 1;
+ while (end < cps.len and cps[end] > 0xF) end += 1;
+ self.handler.vt(.print_slice, .{ .cps = cps[i..end] });
+ i = end;
}
// Consume the bytes we just processed.
offset += res.consumed;
@@ -570,12 +626,202 @@ pub fn Stream(comptime H: type) type {
var offset: usize = 0;
while (self.parser.state != .ground) {
if (offset >= input.len) return input.len;
+
+ // Fast path for CSI entry: "ESC [" is by far the most
+ // common escape sequence prefix, so handle the '[' and
+ // the byte that follows it here rather than paying a
+ // nextNonUtf8 call for each.
+ if (self.parser.state == .escape and input[offset] == '[') {
+ self.parser.state = .csi_entry;
+ offset += 1;
+ continue;
+ }
+
+ if (comptime !@hasDecl(T, "vtRaw")) {
+ if (self.parser.state == .csi_entry) {
+ if (self.csiEntryByte(input[offset])) {
+ offset += 1;
+ continue;
+ }
+ }
+
+ // Bulk-consume CSI parameter bytes. This can't be
+ // used for handlers with a vtRaw hook because it
+ // dispatches the CSI directly (see nextNonUtf8).
+ if (self.parser.state == .csi_param) {
+ offset += self.consumeCsiParams(input[offset..]);
+ if (offset >= input.len) return input.len;
+ // If we're still in csi_param then the next byte
+ // isn't a parameter byte; let nextNonUtf8 below
+ // handle it. Otherwise re-check our state.
+ if (self.parser.state != .csi_param) continue;
+ }
+
+ // Bulk-consume APC string bytes into a single slice.
+ // APC payloads (e.g. Kitty graphics) can be megabytes
+ // of base64 data, so per-byte dispatch is far too slow.
+ // This can't be used for handlers with a vtRaw hook
+ // because it dispatches the slice directly.
+ if (self.parser.state == .sos_pm_apc_string) {
+ offset += self.consumeApcString(input[offset..]);
+ if (offset >= input.len) return input.len;
+ // The next byte exits the string state; let
+ // nextNonUtf8 below handle it.
+ }
+ }
+
self.nextNonUtf8(input[offset]);
offset += 1;
}
return offset;
}
+ /// Fast path for a byte in the csi_entry state, the state right
+ /// after "ESC [". Virtually every CSI sequence spends exactly
+ /// one byte in this state, on either a digit, a private marker,
+ /// or a final byte. Returns true if the byte was fully handled;
+ /// false means the caller must process it through the general
+ /// state machine.
+ ///
+ /// Must not be used by handlers with a vtRaw hook because the
+ /// final byte case dispatches the CSI directly.
+ inline fn csiEntryByte(self: *Self, c: u8) bool {
+ comptime assert(!@hasDecl(T, "vtRaw"));
+ assert(self.parser.state == .csi_entry);
+ switch (c) {
+ // First parameter digit.
+ '0'...'9' => {
+ self.parser.state = .csi_param;
+ // param_acc is zero (cleared on escape entry)
+ // so accumulating is just the digit value.
+ self.parser.param_acc = c - '0';
+ self.parser.param_acc_idx = 1;
+ },
+ // An empty first parameter.
+ ';' => {
+ self.parser.state = .csi_param;
+ self.parser.params[0] = 0;
+ self.parser.params_idx = 1;
+ },
+ // Private marker (e.g. '?' in "ESC [ ? 2004 h").
+ 0x3C...0x3F => {
+ self.parser.state = .csi_param;
+ self.parser.collect(c);
+ },
+ // A final byte: a parameterless CSI.
+ 0x40...0x7E => self.csiDispatchFinal(c),
+ // Defer to the state machine for anything else
+ // (C0 controls, intermediates, colon).
+ else => return false,
+ }
+ return true;
+ }
+
+ /// Bulk-consume CSI parameter bytes (digits and separators)
+ /// and, if reached, the final byte (dispatching the CSI).
+ /// Returns the number of bytes consumed. Stops at the first
+ /// byte that isn't handled here, leaving the parser in the
+ /// csi_param state so the caller can process that byte.
+ fn consumeCsiParams(self: *Self, input: []const u8) usize {
+ const p = &self.parser;
+ assert(p.state == .csi_param);
+
+ // Accumulate parser state in locals for the hot loop.
+ var acc = p.param_acc;
+ var acc_idx = p.param_acc_idx;
+ var idx = p.params_idx;
+
+ var offset: usize = 0;
+ while (offset < input.len) {
+ const c = input[offset];
+ switch (c) {
+ // A parameter digit.
+ '0'...'9' => {
+ if (idx < Parser.MAX_PARAMS) {
+ acc *|= 10;
+ acc +|= c - '0';
+ acc_idx |= 1;
+ }
+ offset += 1;
+ },
+
+ // A parameter separator.
+ ':', ';' => {
+ if (idx < Parser.MAX_PARAMS) {
+ p.params[idx] = acc;
+ if (c == ':') p.params_sep.set(idx);
+ idx += 1;
+ acc = 0;
+ acc_idx = 0;
+ }
+ offset += 1;
+ },
+
+ // A final byte: dispatch the CSI.
+ 0x40...0x7E => {
+ p.param_acc = acc;
+ p.param_acc_idx = acc_idx;
+ p.params_idx = idx;
+ self.csiDispatchFinal(c);
+ return offset + 1;
+ },
+
+ // Anything else (C0 controls, intermediates, etc.)
+ // is handled by the caller.
+ else => break,
+ }
+ }
+
+ p.param_acc = acc;
+ p.param_acc_idx = acc_idx;
+ p.params_idx = idx;
+ return offset;
+ }
+
+ /// Bulk-consume APC string bytes and dispatch them as a single
+ /// apc_put_slice action. Returns the number of bytes consumed.
+ /// Stops at the first byte that is not an apc_put byte in the
+ /// parse table, leaving it for the caller to process through
+ /// the state machine. CAN, SUB, ESC, and most C1 bytes exit
+ /// or abort the string state; 0xA0-0xFF are ignored by the
+ /// table (not payload), so they can't be bulk-consumed either.
+ ///
+ /// Must not be used by handlers with a vtRaw hook because it
+ /// dispatches the slice directly.
+ fn consumeApcString(self: *Self, input: []const u8) usize {
+ comptime assert(!@hasDecl(T, "vtRaw"));
+ assert(self.parser.state == .sos_pm_apc_string);
+
+ var end: usize = 0;
+ if (comptime std.simd.suggestVectorLength(u8)) |vector_len| {
+ const ByteVector = @Vector(vector_len, u8);
+ while (end + vector_len <= input.len) {
+ const bytes: ByteVector = input[end..][0..vector_len].*;
+ const invalid = (bytes == @as(ByteVector, @splat(0x18))) |
+ (bytes == @as(ByteVector, @splat(0x1A))) |
+ (bytes == @as(ByteVector, @splat(0x1B))) |
+ (bytes >= @as(ByteVector, @splat(0x80)));
+ if (@reduce(.Or, invalid)) break;
+ end += vector_len;
+ }
+ }
+ while (end < input.len) {
+ switch (input[end]) {
+ // Not apc_put bytes: CAN/SUB/ESC and most C1 exit
+ // or abort the state; 0xA0-0xFF are ignored by it.
+ 0x18, 0x1A, 0x1B, 0x80...0xFF => break,
+ // Everything else is an apc_put byte.
+ else => end += 1,
+ }
+ }
+
+ if (end > 0) self.handler.vt(
+ .apc_put_slice,
+ .{ .bytes = input[0..end] },
+ );
+ return end;
+ }
+
/// Like nextSlice but takes one byte and is necessarily a scalar
/// operation that can't use SIMD. Prefer nextSlice if you can and
/// try to get multiple bytes at once.
@@ -654,6 +900,13 @@ pub fn Stream(comptime H: type) type {
self.parser.state = .csi_entry;
return;
}
+
+ // The fast paths below dispatch actions directly rather than
+ // going through Parser.next, so they'd bypass a handler's
+ // vtRaw hook. Handlers with vtRaw (e.g. the inspector) use
+ // the general path for anything that produces an action.
+ const has_vt_raw = comptime @hasDecl(T, "vtRaw");
+
// Fast path for CSI params.
if (self.parser.state == .csi_param) csi_param: {
// csi_param is the most common parser state
@@ -690,6 +943,10 @@ pub fn Stream(comptime H: type) type {
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
},
+ // A final byte: dispatch the CSI directly.
+ 0x40...0x7E => if (comptime !has_vt_raw) {
+ self.csiDispatchFinal(c);
+ } else break :csi_param,
// Explicitly ignored:
0x7F => {},
// Defer to the state machine to
@@ -699,6 +956,13 @@ pub fn Stream(comptime H: type) type {
return;
}
+ // Fast path for CSI entry, the state right after "ESC [".
+ if (comptime !has_vt_raw) {
+ if (self.parser.state == .csi_entry) {
+ if (self.csiEntryByte(c)) return;
+ }
+ }
+
// We explicitly inline this call here for performance reasons.
//
// We do this rather than mark Parser.next as inline because doing
@@ -744,6 +1008,48 @@ pub fn Stream(comptime H: type) type {
}
}
+ /// Finalize and dispatch a CSI directly from parser state for
+ /// the fast paths in nextNonUtf8, without going through
+ /// Parser.next. This must match the behavior of the parser's
+ /// csi_dispatch action.
+ fn csiDispatchFinal(self: *Self, c: u8) void {
+ const p = &self.parser;
+ p.state = .ground;
+
+ // Ignore sequences with too many parameters, matching the
+ // parser's behavior of dropping the dispatch entirely.
+ if (p.params_idx >= Parser.MAX_PARAMS) {
+ @branchHint(.unlikely);
+ return;
+ }
+
+ // Finalize the last parameter if we have one.
+ if (p.param_acc_idx > 0) {
+ p.params[p.params_idx] = p.param_acc;
+ p.params_idx += 1;
+ }
+
+ const action: Parser.Action.CSI = .{
+ .intermediates = p.intermediates[0..p.intermediates_idx],
+ .params = p.params[0..p.params_idx],
+ .params_sep = p.params_sep,
+ .final = c,
+ };
+
+ // We only allow colon or mixed separators for the 'm' command.
+ if (c != 'm' and p.params_sep.count() > 0) {
+ @branchHint(.cold);
+ log.warn(
+ "CSI colon or mixed separators only allowed for 'm' command, got: {f}",
+ .{action},
+ );
+ return;
+ }
+
+ if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }});
+ self.csiDispatch(action);
+ }
+
inline fn print(self: *Self, c: u21) void {
self.handler.vt(.print, .{ .cp = c });
}
@@ -776,7 +1082,7 @@ pub fn Stream(comptime H: type) type {
.SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }),
.SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }),
- else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}),
+ else => logUnsupportedOnce("invalid C0 character, ignoring: 0x{x}", .{c}, c),
}
}
@@ -1294,7 +1600,11 @@ pub fn Stream(comptime H: type) type {
if (req) |r| {
self.handler.vt(.device_attributes, r);
} else {
- log.warn("invalid device attributes command: {f}", .{input});
+ logUnsupportedOnce(
+ "invalid device attributes command: {f}",
+ .{input},
+ if (input.params.len > 0) input.params[0] else 0,
+ );
return;
}
},
@@ -1380,7 +1690,7 @@ pub fn Stream(comptime H: type) type {
if (modes.modeFromInt(mode_int, ansi_mode)) |mode| {
self.handler.vt(.set_mode, .{ .mode = mode });
} else {
- log.warn("unimplemented mode: {}", .{mode_int});
+ logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int);
}
}
},
@@ -1401,7 +1711,7 @@ pub fn Stream(comptime H: type) type {
if (modes.modeFromInt(mode_int, ansi_mode)) |mode| {
self.handler.vt(.reset_mode, .{ .mode = mode });
} else {
- log.warn("unimplemented mode: {}", .{mode_int});
+ logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int);
}
}
},
@@ -1470,9 +1780,10 @@ pub fn Stream(comptime H: type) type {
self.handler.vt(.modify_key_format, format);
},
- else => log.warn(
+ else => logUnsupportedOnce(
"unknown CSI m with intermediate: {}",
.{input.intermediates[0]},
+ input.intermediates[0],
),
},
@@ -1804,13 +2115,15 @@ pub fn Stream(comptime H: type) type {
23 => self.handler.vt(.title_pop, index),
else => @compileError("unreachable"),
}
- } else log.warn(
+ } else logUnsupportedOnce(
"ignoring CSI 22/23 t with extra parameters: {f}",
.{input},
+ input.params[0],
),
- else => log.warn(
+ else => logUnsupportedOnce(
"ignoring CSI t with unimplemented parameter: {f}",
.{input},
+ input.params[0],
),
}
} else log.err(
@@ -1985,7 +2298,11 @@ pub fn Stream(comptime H: type) type {
.change_window_icon => |icon| {
@branchHint(.likely);
- log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon});
+ logUnsupportedOnce(
+ "OSC 1 (change icon) received and ignored icon={s}",
+ .{icon},
+ 0,
+ );
},
.clipboard_contents => |clip| {
@@ -2058,6 +2375,7 @@ pub fn Stream(comptime H: type) type {
.conemu_run_process,
.kitty_text_sizing,
.kitty_clipboard_protocol,
+ .kitty_dnd_protocol,
.context_signal,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});
@@ -2176,7 +2494,11 @@ pub fn Stream(comptime H: type) type {
else => {}, // fall through
}
- log.warn("unimplemented ESC action: {f}", .{action});
+ logUnsupportedOnce(
+ "unimplemented ESC action: {f}",
+ .{action},
+ action.final,
+ );
},
// IND - Index
@@ -2370,12 +2692,72 @@ pub fn Stream(comptime H: type) type {
@branchHint(.likely);
},
- else => log.warn("unimplemented ESC action: {f}", .{action}),
+ else => logUnsupportedOnce(
+ "unimplemented ESC action: {f}",
+ .{action},
+ action.final,
+ ),
}
}
};
}
+/// Logs an unsupported-input message at most once per distinct key
+/// per process.
+///
+/// These messages are emitted in response to input that the terminal
+/// application controls, so a misbehaving (or merely chatty) program
+/// can trigger the same message millions of times, e.g. by toggling
+/// an unimplemented mode on every frame. Each log call has a real
+/// throughput cost (formatting plus a blocking write per message)
+/// while adding no diagnostic value beyond the first occurrence.
+///
+/// The keys seen so far are tracked in a small fixed table (64 bytes)
+/// instantiated per (format, argument type) tuple, i.e. roughly per
+/// call site. Real streams only ever produce a handful of distinct
+/// unsupported values per site, so if the table ever fills, messages
+/// for further new values are suppressed as well: by that point the
+/// log already shows this class of problem and unbounded distinct
+/// values would flood it anyway.
+fn logUnsupportedOnce(
+ comptime format: []const u8,
+ args: anytype,
+ key: u16,
+) void {
+ // u32 slots so every u16 key is representable alongside an empty
+ // sentinel and so 32-bit targets (e.g. wasm32) have native
+ // atomics.
+ const empty = std.math.maxInt(u32);
+ const Static = struct {
+ var seen: [16]u32 = @splat(empty);
+ };
+
+ // The atomics make concurrent streams safe: slots are only ever
+ // claimed, never changed, so the scan can stop at the first empty
+ // slot. The worst case race is a benign duplicate message.
+ for (&Static.seen) |*slot| {
+ const cur = @atomicLoad(u32, slot, .acquire);
+ if (cur == key) return; // already logged
+ if (cur != empty) continue; // other key, keep scanning
+
+ // Empty slot: claim it for this key and log below.
+ const actual = @cmpxchgStrong(
+ u32,
+ slot,
+ empty,
+ key,
+ .acq_rel,
+ .acquire,
+ ) orelse break;
+
+ // Lost the race: suppress if it was to the same key, keep
+ // scanning otherwise.
+ if (actual == key) return;
+ } else return; // table full: suppress new values too
+
+ log.warn(format, args);
+}
+
test Action {
// Forces the C type to be reified when the target is C, ensuring
// all our types are C ABI compatible.
@@ -2414,6 +2796,7 @@ test "simd: print invalid utf-8" {
) void {
switch (action) {
.print => self.c = value.cp,
+ .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
else => {},
}
}
@@ -2435,6 +2818,7 @@ test "simd: complete incomplete utf-8" {
) void {
switch (action) {
.print => self.c = value.cp,
+ .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
else => {},
}
}
@@ -3447,3 +3831,130 @@ test "stream: tab clear with overflowing param" {
// CSI with a huge numeric param that saturates to 65535, followed by 'g'.
s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m");
}
+
+/// A test handler that accumulates APC bytes regardless of whether they
+/// arrive per-byte (apc_put) or in bulk (apc_put_slice).
+const ApcTestHandler = struct {
+ buf: [256]u8 = undefined,
+ len: usize = 0,
+ slices: usize = 0,
+ puts: usize = 0,
+ started: usize = 0,
+ ended: usize = 0,
+
+ pub fn vt(
+ self: *@This(),
+ comptime action: Action.Tag,
+ value: Action.Value(action),
+ ) void {
+ switch (action) {
+ .apc_start => self.started += 1,
+ .apc_end => self.ended += 1,
+ .apc_put => {
+ self.buf[self.len] = value;
+ self.len += 1;
+ self.puts += 1;
+ },
+ .apc_put_slice => {
+ @memcpy(self.buf[self.len..][0..value.bytes.len], value.bytes);
+ self.len += value.bytes.len;
+ self.slices += 1;
+ },
+ else => {},
+ }
+ }
+};
+
+test "stream: apc bulk slice" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gf=24,s=10,v=20;aGVsbG8=",
+ s.handler.buf[0..s.handler.len],
+ );
+
+ // With SIMD enabled the body must arrive as a single slice.
+ if (comptime build_options.simd and !debug) {
+ try testing.expectEqual(@as(usize, 1), s.handler.slices);
+ try testing.expectEqual(@as(usize, 0), s.handler.puts);
+ }
+}
+
+test "stream: apc bulk slice split across inputs" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ s.nextSlice("\x1b_Gf=24,s=10");
+ s.nextSlice(",v=20;aGVs");
+ s.nextSlice("bG8=\x1b\\");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gf=24,s=10,v=20;aGVsbG8=",
+ s.handler.buf[0..s.handler.len],
+ );
+}
+
+test "stream: apc bulk slice keeps C0 bytes as data" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ // BEL does not terminate an APC string; it is payload data.
+ s.nextSlice("\x1b_Gx\x07y\x1b\\");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings("Gx\x07y", s.handler.buf[0..s.handler.len]);
+}
+
+test "stream: apc aborted by CAN" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ // CAN (0x18) aborts the APC string via the anywhere => ground
+ // transition. Exiting the sos_pm_apc_string state emits apc_end,
+ // and the trailing bytes are printed, not treated as APC data.
+ s.nextSlice("\x1b_Gabcdefghijklmnopqrstuvwxyz0123456789\x18def");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gabcdefghijklmnopqrstuvwxyz0123456789",
+ s.handler.buf[0..s.handler.len],
+ );
+}
+
+test "stream: apc scalar path matches" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c);
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gf=24;aGVsbG8=",
+ s.handler.buf[0..s.handler.len],
+ );
+}
+
+test "stream: apc vector boundaries match scalar path" {
+ const positions = [_]usize{ 15, 16, 17, 31, 32, 33, 63, 64, 65 };
+ const controls = [_]u8{ 0x18, 0x1A, 0x1B, 0x80, 0xFF };
+
+ for (positions) |position| for (controls) |control| {
+ var input: [96]u8 = undefined;
+ input[0..3].* = "\x1b_G".*;
+ @memset(input[3 .. 3 + position], 'a');
+ input[3 + position] = control;
+ input[4 + position] = '\\';
+ const bytes = input[0 .. 5 + position];
+
+ var bulk: Stream(ApcTestHandler) = .init(.{});
+ bulk.nextSlice(bytes);
+ var scalar: Stream(ApcTestHandler) = .init(.{});
+ for (bytes) |byte| scalar.next(byte);
+
+ try testing.expectEqual(scalar.handler.started, bulk.handler.started);
+ try testing.expectEqual(scalar.handler.ended, bulk.handler.ended);
+ try testing.expectEqualStrings(
+ scalar.handler.buf[0..scalar.handler.len],
+ bulk.handler.buf[0..bulk.handler.len],
+ );
+ };
+}