diff --git a/src/lib/union.zig b/src/lib/union.zig index 728b13304..7ed3fc792 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -132,13 +132,7 @@ pub fn TaggedUnion( /// Returns the value type for the given tag. pub fn Value(comptime tag: Tag) type { - @setEvalBranchQuota(10000); - inline for (@typeInfo(Union).@"union".fields) |field| { - const field_tag = @field(Tag, field.name); - if (field_tag == tag) return field.type; - } - - unreachable; + return @FieldType(Union, @tagName(tag)); } }; } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 677152518..dbc35492a 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -540,6 +540,39 @@ pub const Parser = struct { try self.writer.writeByte(byte); } + /// Append a slice without permitting the backing allocation to + /// grow beyond max_bytes. This matches the byte-at-a-time + /// semantics of writeByte: bytes are retained up to exactly + /// max_bytes and the first byte that doesn't fit fails the + /// write. + pub fn writeSlice( + self: *Capture, + bytes: []const u8, + ) error{WriteFailed}!void { + const avail = self.max_bytes - self.writer.buffered().len; + const n = @min(bytes.len, avail); + + switch (self.backing) { + .fixed => {}, + .allocating => |*w| { + const needed = w.writer.end + n; + if (needed > w.writer.buffer.len) { + const new_capacity = @min( + self.max_bytes, + @max(w.writer.buffer.len *| 2, needed), + ); + w.writer.buffer = w.allocator.realloc( + w.writer.buffer, + new_capacity, + ) catch return error.WriteFailed; + } + }, + } + + try self.writer.writeAll(bytes[0..n]); + if (n < bytes.len) return error.WriteFailed; + } + pub fn deinit(self: *Capture) void { switch (self.backing) { .fixed => {}, @@ -594,6 +627,33 @@ pub const Parser = struct { } } + /// Consume a slice of bytes, advancing the parser state. This is + /// equivalent to calling `next` for each byte in order, but is much + /// faster once a data capture is active because the remaining bytes + /// are appended to the capture in bulk. + pub fn nextSlice(self: *Parser, input: []const u8) void { + if (self.state == .invalid) return; + + // Run the state machine byte-at-a-time until a capture begins. + // The command prefix before a capture starts is only a handful + // of bytes so this loop is short in practice. + var offset: usize = 0; + while (self.capture == null) { + if (offset >= input.len) return; + self.next(input[offset]); + offset += 1; + if (self.state == .invalid) return; + } + + const rem = input[offset..]; + if (rem.len == 0) return; + self.capture.?.writeSlice(rem) 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, + }; + } + /// Consume the next character c and advance the parser state. pub fn next(self: *Parser, c: u8) void { // If the state becomes invalid for any reason, just discard @@ -934,6 +994,70 @@ test "Parser allocating captures have a hard limit" { } } +test "Parser nextSlice allocating captures have a hard limit" { + const testing = std.testing; + const limit = Parser.MAX_BUF + 1; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = limit; + + const data = try testing.allocator.alloc(u8, limit); + defer testing.allocator.free(data); + @memset(data, 'a'); + + // Exactly at the limit stays valid and bounded. + p.nextSlice("52;"); + p.nextSlice(data); + const cap = &p.capture.?; + try testing.expect(p.state != .invalid); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + + // One more byte overflows: the state becomes invalid and the + // retained bytes and allocation stay bounded. + p.nextSlice("a"); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + try testing.expect(p.end(null) == null); +} + +test "Parser nextSlice overflowing slice is truncated at the limit" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = 4; + + p.nextSlice("52;abcdef"); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expect(p.end(null) == null); + + const cap = &p.capture.?; + try testing.expectEqualStrings("abcd", cap.trailing()); + try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len); +} + +test "Parser nextSlice matches per-byte parsing" { + const testing = std.testing; + const input = "52;c;aGVsbG8="; + + // Every two-way split of the input must parse identically to + // the byte-at-a-time path. + for (0..input.len + 1) |split| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.nextSlice(input[0..split]); + p.nextSlice(input[split..]); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual(@as(u8, 'c'), cmd.clipboard_contents.kind); + try testing.expectEqualStrings("aGVsbG8=", cmd.clipboard_contents.data); + } +} + test "Parser allocating capture limit includes parser-added bytes" { const testing = std.testing; var p: Parser = .init(testing.allocator); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index cd62a316a..e6bd55f1b 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -857,6 +857,47 @@ pub fn Stream(comptime H: type) type { // Aborting transitions need the scalar path so the // handler can distinguish them from terminators. } + + // Bulk-consume OSC string bytes into the OSC parser. + // OSC payloads (e.g. OSC 52 clipboard operations and + // the kitty clipboard protocol) 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 the bytes never produce osc_put actions. + if (self.parser.state == .osc_string) { + offset += self.consumeOscString(input[offset..]); + if (offset >= input.len) return input.len; + + // Fast-path normal string termination. This matches + // Parser.next's exit and entry actions while avoiding + // the generic action loop for every completed OSC. + switch (input[offset]) { + // The sequence should be terminated by a full + // ST ("ESC \"); the "\" that should follow is + // dispatched as a normal escape sequence. + std.ascii.control_code.esc => { + if (self.parser.osc_parser.end( + std.ascii.control_code.esc, + )) |cmd| self.oscDispatch(cmd.*); + self.parser.clear(); + self.parser.state = .escape; + offset += 1; + continue; + }, + // BEL terminates the string directly. + std.ascii.control_code.bel => { + if (self.parser.osc_parser.end( + std.ascii.control_code.bel, + )) |cmd| self.oscDispatch(cmd.*); + self.parser.state = .ground; + offset += 1; + continue; + }, + // CAN/SUB abort and ignored C0 bytes go + // through the state machine below. + else => {}, + } + } } self.nextNonUtf8(input[offset]); @@ -1011,6 +1052,43 @@ pub fn Stream(comptime H: type) type { return end; } + /// Bulk-consume OSC string bytes into the OSC parser. Returns + /// the number of bytes consumed. Stops at the first byte that + /// is not an osc_put byte in the parse table, leaving it for + /// the caller to process through the state machine. Every byte + /// >= 0x20 is an osc_put byte; bytes below either terminate or + /// abort the string (BEL, CAN, SUB, ESC) or are ignored. + /// + /// Must not be used by handlers with a vtRaw hook because the + /// consumed bytes never produce osc_put actions. + fn consumeOscString(self: *Self, input: []const u8) usize { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .osc_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 stop = bytes < @as(ByteVector, @splat(0x20)); + if (@reduce(.Or, stop)) break; + end += vector_len; + } + } + while (end < input.len) { + switch (input[end]) { + // Not osc_put bytes: BEL/CAN/SUB/ESC terminate or + // abort the state; other C0 bytes are ignored by it. + 0x00...0x1F => break, + // Everything else is an osc_put byte. + else => end += 1, + } + } + + if (end > 0) self.parser.osc_parser.nextSlice(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. @@ -3534,6 +3612,191 @@ test "stream: change window title with invalid utf-8" { } } +test "stream: osc 52 large payload in chunks" { + const alloc = testing.allocator; + + const H = struct { + const Self = @This(); + data: std.ArrayListUnmanaged(u8) = .empty, + kind: u8 = 0, + terminator: ?osc.Terminator = null, + count: usize = 0, + + pub fn vt( + self: *Self, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .clipboard_contents => { + self.count += 1; + self.kind = value.kind; + self.terminator = value.terminator; + self.data.appendSlice( + testing.allocator, + value.data, + ) catch @panic("OOM"); + }, + else => {}, + } + } + }; + + // A payload that far exceeds the fixed buffer so the allocating + // capture is used, fed in chunk sizes that don't align with the + // sequence so the bulk path sees every kind of boundary. + const prefix = "\x1b]52;c;"; + const payload_len = 150_000; + var input: std.ArrayListUnmanaged(u8) = .empty; + defer input.deinit(alloc); + try input.appendSlice(alloc, prefix); + for (0..payload_len) |i| try input.append( + alloc, + 'A' + @as(u8, @intCast(i % 26)), + ); + try input.appendSlice(alloc, "\x1b\\"); + + var s: Stream(H) = .init(.{ .handler = .{}, .allocator = alloc }); + defer s.parser.deinit(); + defer s.handler.data.deinit(alloc); + + var i: usize = 0; + while (i < input.items.len) { + const end = @min(i + 4093, input.items.len); + s.nextSlice(input.items[i..end]); + i = end; + } + + try testing.expectEqual(@as(usize, 1), s.handler.count); + try testing.expectEqual(@as(u8, 'c'), s.handler.kind); + try testing.expectEqual(osc.Terminator.st, s.handler.terminator.?); + try testing.expectEqualSlices( + u8, + input.items[prefix.len .. prefix.len + payload_len], + s.handler.data.items, + ); +} + +test "stream: osc bulk path matches per-byte path" { + const alloc = testing.allocator; + + // Records every dispatch relevant to OSC processing in a + // normalized text form so streams fed different ways can be + // compared byte-for-byte. + const H = struct { + const Self = @This(); + alloc: Allocator, + journal: std.ArrayListUnmanaged(u8) = .empty, + + fn record(self: *Self, comptime fmt: []const u8, args: anytype) void { + const s = std.fmt.allocPrint( + self.alloc, + fmt, + args, + ) catch @panic("OOM"); + defer self.alloc.free(s); + self.journal.appendSlice(self.alloc, s) catch @panic("OOM"); + } + + pub fn vt( + self: *Self, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .clipboard_contents => self.record( + "clip kind={c} term={s} data={s}\n", + .{ value.kind, @tagName(value.terminator), value.data }, + ), + .kitty_clipboard => self.record( + "kitty meta={s} payload={?s}\n", + .{ value.metadata, value.payload }, + ), + .window_title => self.record( + "title {s}\n", + .{value.title}, + ), + // Normalize prints to per-codepoint so the per-byte + // and slice paths journal identically. + .print => self.record("print {u}\n", .{value.cp}), + .print_slice => for (value.cps) |cp| self.record( + "print {u}\n", + .{@as(u21, @intCast(cp))}, + ), + else => {}, + } + } + }; + + const cases = [_][]const u8{ + "\x1b]52;c;aGVsbG8=\x1b\\", + "\x1b]52;c;aGVsbG8=\x07", + "\x1b]52;;aGVsbG8=\x07", + // Ignored C0 byte embedded in the payload. + "\x1b]52;c;aGVs\x01bG8=\x07", + // CAN and SUB aborts. + "\x1b]52;c;aGVsbG8=\x18", + "\x1b]52;c;aGVsbG8=\x1a", + // C1 byte embedded in the payload is data. + "\x1b]0;ab\x9ccd\x07", + // Terminated with trailing printable text. + "\x1b]0;a title\x07x", + // Back-to-back sequences. + "\x1b]2;another title\x1b\\\x1b]0;t2\x07", + "\x1b]5522;type=write;aGVsbG8=\x1b\\", + // Invalid OSC number. + "\x1b]999;junk\x07", + // Exceeds the fixed buffer: allocating capture. + "\x1b]52;c;" ++ "y" ** 3000 ++ "\x1b\\", + // Exceeds the fixed buffer: overflow, no dispatch. + "\x1b]0;" ++ "x" ** 3000 ++ "\x07", + }; + + for (cases) |case| { + // Reference: byte-at-a-time. + var ref: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer ref.parser.deinit(); + defer ref.handler.journal.deinit(alloc); + for (case) |c| ref.next(c); + + // The whole slice at once. + { + var s: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer s.parser.deinit(); + defer s.handler.journal.deinit(alloc); + s.nextSlice(case); + try testing.expectEqualSlices( + u8, + ref.handler.journal.items, + s.handler.journal.items, + ); + } + + // Split into two slices at every possible boundary. + for (0..case.len + 1) |split| { + var s: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer s.parser.deinit(); + defer s.handler.journal.deinit(alloc); + s.nextSlice(case[0..split]); + s.nextSlice(case[split..]); + try testing.expectEqualSlices( + u8, + ref.handler.journal.items, + s.handler.journal.items, + ); + } + } +} + test "stream: insert characters" { const H = struct { const Self = @This();