diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/stream.zig | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (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.zig | 537 |
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], + ); + }; +} |
