Merge 43db1c207f into 5758e14931
commit
f21be02ed4
|
|
@ -18,6 +18,13 @@ pub const Handler = struct {
|
|||
/// This is arbitrarily set to 1MB today, increase if needed.
|
||||
max_bytes: usize = 1024 * 1024,
|
||||
|
||||
/// Tracks whether we received an ESC byte that could be the start
|
||||
/// of a 7-bit ST (ESC \). Because ESC is now forwarded as .put in
|
||||
/// dcs_passthrough (to prevent premature DCS termination from ESC
|
||||
/// bytes embedded in tmux control mode output), we must detect
|
||||
/// 7-bit ST ourselves.
|
||||
pending_esc: bool = false,
|
||||
|
||||
pub fn deinit(self: *Handler) void {
|
||||
self.discard();
|
||||
}
|
||||
|
|
@ -27,6 +34,7 @@ pub const Handler = struct {
|
|||
|
||||
// Initialize our state to ignore in case of error
|
||||
self.state = .ignore;
|
||||
self.pending_esc = false;
|
||||
|
||||
// Try to parse the hook.
|
||||
const hk_ = self.tryHook(alloc, dcs) catch |err| {
|
||||
|
|
@ -112,8 +120,34 @@ pub const Handler = struct {
|
|||
/// Put a byte into the DCS handler. This will return a command
|
||||
/// if a command needs to be executed.
|
||||
pub fn put(self: *Handler, byte: u8) ?Command {
|
||||
// Handle 7-bit ST detection. Because ESC (0x1B) is now forwarded
|
||||
// as .put in dcs_passthrough (rather than triggering a state
|
||||
// transition to .escape), we must detect the ESC + '\' sequence
|
||||
// that forms 7-bit ST ourselves.
|
||||
if (self.pending_esc) {
|
||||
self.pending_esc = false;
|
||||
if (byte == 0x5C) {
|
||||
// ESC \ = 7-bit ST → terminate DCS, same as unhook()
|
||||
return self.unhook();
|
||||
}
|
||||
|
||||
// Not ST: forward the stored ESC to the sub-handler first,
|
||||
// then fall through to handle the current byte normally.
|
||||
if (self.forwardPut(0x1B)) |cmd| return cmd;
|
||||
return self.forwardPut(byte);
|
||||
}
|
||||
|
||||
if (byte == 0x1B) {
|
||||
self.pending_esc = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
return self.forwardPut(byte);
|
||||
}
|
||||
|
||||
/// Forward a byte to the appropriate sub-handler.
|
||||
fn forwardPut(self: *Handler, byte: u8) ?Command {
|
||||
return self.tryPut(byte) catch |err| {
|
||||
// On error we just discard our state and ignore the rest
|
||||
log.info("error putting byte into DCS handler err={}", .{err});
|
||||
self.discard();
|
||||
self.state = .ignore;
|
||||
|
|
@ -158,6 +192,7 @@ pub const Handler = struct {
|
|||
// Note: we do NOT call deinit here on purpose because some commands
|
||||
// transfer memory ownership. If state needs cleanup, the switch
|
||||
// prong below should handle it.
|
||||
self.pending_esc = false;
|
||||
defer self.state = .inactive;
|
||||
|
||||
return switch (self.state) {
|
||||
|
|
@ -199,6 +234,7 @@ pub const Handler = struct {
|
|||
}
|
||||
|
||||
fn discard(self: *Handler) void {
|
||||
self.pending_esc = false;
|
||||
self.state.deinit();
|
||||
self.state = .inactive;
|
||||
}
|
||||
|
|
@ -428,3 +464,139 @@ test "tmux enter and implicit exit" {
|
|||
try testing.expect(cmd.tmux == .exit);
|
||||
}
|
||||
}
|
||||
|
||||
test "7-bit ST (ESC \\) terminates DCS via put" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Use XTGETTCAP as a simple DCS to test with
|
||||
var h: Handler = .{};
|
||||
defer h.deinit();
|
||||
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
||||
|
||||
// Put some data
|
||||
for ("536D756C78") |byte| _ = h.put(byte);
|
||||
|
||||
// Now send ESC \ (7-bit ST)
|
||||
try testing.expect(h.put(0x1B) == null); // ESC buffered
|
||||
var cmd = h.put(0x5C).?; // '\' completes ST → unhook
|
||||
defer cmd.deinit();
|
||||
try testing.expect(cmd == .xtgettcap);
|
||||
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
|
||||
try testing.expect(cmd.xtgettcap.next() == null);
|
||||
|
||||
// Handler should be inactive after ST
|
||||
try testing.expect(h.state == .inactive);
|
||||
try testing.expect(h.pending_esc == false);
|
||||
}
|
||||
|
||||
test "ESC followed by non-backslash is forwarded" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Use XTGETTCAP as a simple DCS to test with
|
||||
var h: Handler = .{};
|
||||
defer h.deinit();
|
||||
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
||||
|
||||
// Put some data
|
||||
for ("AB") |byte| _ = h.put(byte);
|
||||
|
||||
// Send ESC followed by '[' (not ST — this is CSI start)
|
||||
try testing.expect(h.put(0x1B) == null); // ESC buffered
|
||||
try testing.expect(h.put('[') == null); // Not ST, both bytes forwarded
|
||||
|
||||
// The ESC and '[' should be in the buffer
|
||||
var cmd = h.unhook().?;
|
||||
defer cmd.deinit();
|
||||
try testing.expect(cmd == .xtgettcap);
|
||||
// Buffer should contain: "AB" + ESC + "["
|
||||
try testing.expectEqualStrings("AB\x1b[", cmd.xtgettcap.next().?);
|
||||
}
|
||||
|
||||
test "tmux: ESC in block content does not cause exit" {
|
||||
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
defer h.deinit();
|
||||
|
||||
{
|
||||
var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
|
||||
defer cmd.deinit();
|
||||
try testing.expect(cmd == .tmux);
|
||||
try testing.expect(cmd.tmux == .enter);
|
||||
}
|
||||
|
||||
// Simulate tmux sending a %begin block with embedded ESC sequences
|
||||
// (like capture-pane -e output with SGR codes)
|
||||
const block_with_esc =
|
||||
"%begin 1234 1 0\n" ++
|
||||
"\x1b[32mhello\x1b[0m\n" ++ // SGR green + reset
|
||||
"%end 1234 1 0\n";
|
||||
|
||||
var got_block_end = false;
|
||||
for (block_with_esc) |byte| {
|
||||
if (h.put(byte)) |cmd| {
|
||||
// We should get a tmux block_end notification, NOT an exit
|
||||
try testing.expect(cmd == .tmux);
|
||||
switch (cmd.tmux) {
|
||||
.exit => return error.TestUnexpectedResult,
|
||||
.block_end => {
|
||||
got_block_end = true;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We should still be in tmux state (not exited)
|
||||
try testing.expect(h.state == .tmux);
|
||||
try testing.expect(got_block_end);
|
||||
}
|
||||
|
||||
test "tmux: 7-bit ST exits tmux control mode" {
|
||||
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
defer h.deinit();
|
||||
|
||||
{
|
||||
var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
|
||||
defer cmd.deinit();
|
||||
try testing.expect(cmd == .tmux);
|
||||
try testing.expect(cmd.tmux == .enter);
|
||||
}
|
||||
|
||||
// Send ESC \ (7-bit ST) to terminate tmux control mode
|
||||
try testing.expect(h.put(0x1B) == null);
|
||||
var cmd = h.put(0x5C).?;
|
||||
defer cmd.deinit();
|
||||
try testing.expect(cmd == .tmux);
|
||||
try testing.expect(cmd.tmux == .exit);
|
||||
try testing.expect(h.state == .inactive);
|
||||
}
|
||||
|
||||
test "pending_esc is cleared on hook" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
defer h.deinit();
|
||||
|
||||
// Manually set pending_esc (simulating leftover state)
|
||||
h.pending_esc = true;
|
||||
|
||||
// Hook into XTGETTCAP — hook should clear pending_esc
|
||||
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
|
||||
try testing.expect(h.pending_esc == false);
|
||||
|
||||
// Clean up: unhook so deinit doesn't leak
|
||||
var cmd = h.unhook().?;
|
||||
cmd.deinit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,9 +238,30 @@ fn genTable() Table {
|
|||
// events
|
||||
single(&result, 0x19, source, source, .put);
|
||||
range(&result, 0, 0x17, source, source, .put);
|
||||
single(&result, 0x18, source, source, .put); // CAN: override "anywhere" → ground
|
||||
single(&result, 0x1A, source, source, .put); // SUB: override "anywhere" → ground
|
||||
single(&result, 0x1B, source, source, .put); // ESC: override "anywhere" → escape
|
||||
range(&result, 0x1C, 0x1F, source, source, .put);
|
||||
range(&result, 0x20, 0x7E, source, source, .put);
|
||||
single(&result, 0x7F, source, source, .ignore);
|
||||
|
||||
// High bytes (0x80-0xFF): override "anywhere" transitions to stay
|
||||
// in dcs_passthrough. tmux control mode protocol data contains raw
|
||||
// UTF-8 in %output pane content. UTF-8 multi-byte sequences use:
|
||||
// - Continuation bytes: 0x80-0xBF
|
||||
// - 2-byte start bytes: 0xC0-0xDF
|
||||
// - 3-byte start bytes: 0xE0-0xEF
|
||||
// - 4-byte start bytes: 0xF0-0xF4
|
||||
//
|
||||
// Without these overrides, bytes >= 0xA0 would default to .none
|
||||
// (silently dropped), mangling all non-ASCII UTF-8 text. Bytes
|
||||
// 0x80-0x9F also need overrides to prevent "anywhere" C1 transitions
|
||||
// from kicking the parser out of dcs_passthrough.
|
||||
//
|
||||
// 0x9C (C1 ST) is NOT overridden — it is the legitimate 8-bit DCS
|
||||
// string terminator and must still transition to ground.
|
||||
range(&result, 0x80, 0x9B, source, source, .put);
|
||||
range(&result, 0x9D, 0xFF, source, source, .put);
|
||||
}
|
||||
|
||||
// csi_param
|
||||
|
|
@ -386,3 +407,124 @@ test {
|
|||
// that it succeeds in creation.
|
||||
_ = table;
|
||||
}
|
||||
|
||||
test "dcs_passthrough: ESC stays in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const entry = t[0x1B][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: CAN stays in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const entry = t[0x18][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: SUB stays in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const entry = t[0x1A][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: C1 0x80 stays in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const entry = t[0x80][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: C1 0x9B (CSI) stays in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const entry = t[0x9B][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: C1 0x9D (OSC) stays in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const entry = t[0x9D][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: C1 ST (0x9C) still transitions to ground" {
|
||||
const t = table;
|
||||
const entry = t[0x9C][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.ground, entry.state);
|
||||
try @import("std").testing.expectEqual(Action.none, entry.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: regular bytes still produce put" {
|
||||
const t = table;
|
||||
// Printable ASCII
|
||||
const entry_a = t['A'][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry_a.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry_a.action);
|
||||
|
||||
// Control byte in put range
|
||||
const entry_0 = t[0x00][@intFromEnum(State.dcs_passthrough)];
|
||||
try @import("std").testing.expectEqual(State.dcs_passthrough, entry_0.state);
|
||||
try @import("std").testing.expectEqual(Action.put, entry_0.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: UTF-8 start bytes (0xC0-0xF4) produce put" {
|
||||
const t = table;
|
||||
const testing = @import("std").testing;
|
||||
|
||||
// 2-byte start: 0xC0 (start of 2-byte range)
|
||||
const entry_c0 = t[0xC0][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_c0.state);
|
||||
try testing.expectEqual(Action.put, entry_c0.action);
|
||||
|
||||
// 2-byte start: 0xDF (end of 2-byte range)
|
||||
const entry_df = t[0xDF][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_df.state);
|
||||
try testing.expectEqual(Action.put, entry_df.action);
|
||||
|
||||
// 3-byte start: 0xE2 (box-drawing U+250x starts with 0xE2)
|
||||
const entry_e2 = t[0xE2][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_e2.state);
|
||||
try testing.expectEqual(Action.put, entry_e2.action);
|
||||
|
||||
// 4-byte start: 0xF0 (emoji U+1Fxxx starts with 0xF0)
|
||||
const entry_f0 = t[0xF0][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_f0.state);
|
||||
try testing.expectEqual(Action.put, entry_f0.action);
|
||||
|
||||
// Last valid 4-byte start: 0xF4
|
||||
const entry_f4 = t[0xF4][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_f4.state);
|
||||
try testing.expectEqual(Action.put, entry_f4.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: UTF-8 continuation bytes 0xA0-0xBF produce put" {
|
||||
const t = table;
|
||||
const testing = @import("std").testing;
|
||||
|
||||
// 0xA0 — first continuation byte above C1 range
|
||||
const entry_a0 = t[0xA0][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_a0.state);
|
||||
try testing.expectEqual(Action.put, entry_a0.action);
|
||||
|
||||
// 0xBF — last continuation byte
|
||||
const entry_bf = t[0xBF][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry_bf.state);
|
||||
try testing.expectEqual(Action.put, entry_bf.action);
|
||||
}
|
||||
|
||||
test "dcs_passthrough: all bytes 0xA0-0xFF stay in dcs_passthrough with put" {
|
||||
const t = table;
|
||||
const testing = @import("std").testing;
|
||||
|
||||
// Verify the entire 0xA0-0xFF range. These bytes appear in UTF-8
|
||||
// encoded text within tmux %output lines and must be forwarded
|
||||
// to the DCS handler, not silently dropped.
|
||||
for (0xA0..0x100) |byte| {
|
||||
const entry = t[byte][@intFromEnum(State.dcs_passthrough)];
|
||||
try testing.expectEqual(State.dcs_passthrough, entry.state);
|
||||
try testing.expectEqual(Action.put, entry.action);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue