From 1ea25c5c641c4cbbb649ad8d56b7de8bf485e53c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 20:51:48 -0700 Subject: [PATCH] terminal: tmux parsing handles begin/end blocks --- src/terminal/dcs.zig | 168 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 161 insertions(+), 7 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 287aa03ad..ee9a39340 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -55,7 +55,14 @@ pub const Handler = struct { if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null; break :tmux .{ - .state = .{ .tmux = {} }, + .state = .{ + .tmux = .{ + .buffer = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice to limit initial reallocs + ), + }, + }, .command = .{ .tmux = .{ .enter = {} } }, }; }, @@ -113,7 +120,7 @@ pub const Handler = struct { .ignore, => {}, - .tmux => {}, + .tmux => |*tmux| return try tmux.put(byte, self.max_bytes), .xtgettcap => |*list| { if (list.items.len >= self.max_bytes) { @@ -147,7 +154,10 @@ pub const Handler = struct { .ignore, => null, - .tmux => .{ .tmux = .{ .exit = {} } }, + .tmux => tmux: { + self.state.deinit(); + break :tmux .{ .tmux = .{ .exit = {} } }; + }, .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, @@ -251,21 +261,143 @@ const State = union(enum) { }, /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode - tmux: void, + tmux: TmuxState, - pub fn deinit(self: State) void { - switch (self) { + pub fn deinit(self: *State) void { + switch (self.*) { .inactive, .ignore, => {}, .xtgettcap => |*v| v.deinit(), .decrqss => {}, - .tmux => {}, + .tmux => |*v| v.deinit(), } } }; +const TmuxState = struct { + tag: Tag = .idle, + buffer: std.ArrayList(u8), + + const Tag = enum { + /// Outside of any active command. This should drop any output + /// unless it is '%' on the first byte of a line. + idle, + + /// We experienced unexpected input and are in a broken state + /// so we cannot continue processing. + broken, + + /// Inside an active command (started with '%'). + command, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *TmuxState) void { + self.buffer.deinit(); + } + + // Handle a byte of input. + pub fn put(self: *TmuxState, byte: u8, max_bytes: usize) !?Command { + if (self.buffer.items.len >= max_bytes) { + self.broken(); + return error.OutOfMemory; + } + + switch (self.tag) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a command so if the byte is not '%' then + // we're in a broken state. Return an exit command. + .idle => if (byte != '%') { + self.broken(); + return .{ .tmux = .{ .exit = {} } }; + } else { + self.tag = .command; + }, + + // If we're in a command and its not a newline then + // we accumulate. If it is a newline then we have a + // complete command we need to parse. + .command => if (byte == '\n') { + // We have a complete command, parse it. + return try self.parseCommand(); + }, + + // If we're ina block then we accumulate until we see a newline + // and then we check to see if that line ended the block. + .block => if (byte == '\n') { + const idx = if (std.mem.lastIndexOfScalar( + u8, + self.buffer.items, + '\n', + )) |v| v + 1 else 0; + const line = self.buffer.items[idx..]; + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + // If it is an error then log it. + if (std.mem.startsWith(u8, line, "%error")) { + const output = self.buffer.items[0..idx]; + log.warn("tmux control mode error={s}", .{output}); + } + + // We ignore the rest of the line, see %begin for why. + self.tag = .idle; + self.buffer.clearRetainingCapacity(); + return null; + } + }, + } + + try self.buffer.append(byte); + + return null; + } + + fn parseCommand(self: *TmuxState) !?Command { + assert(self.tag == .command); + + var it = std.mem.tokenizeScalar(u8, self.buffer.items, ' '); + + // The command MUST exist because we guard entering the command + // state on seeing at least a '%'. + const cmd = it.next().?; + if (std.mem.eql(u8, cmd, "%begin")) { + // We don't use the rest of the tokens for now because tmux + // claims to guarantee that begin/end are always in order and + // never intermixed. In the future, we should probably validate + // this. + // TODO(tmuxcc): do this before merge? + + // Move to block state because we expect a corresponding end/error + // and want to accumulate the data. + self.tag = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else { + // Unknown command, log it and return to idle state. + log.warn("unknown tmux control mode command={s}", .{cmd}); + } + + // Successful exit, revert to idle state. + self.buffer.clearRetainingCapacity(); + self.tag = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *TmuxState) void { + self.tag = .broken; + self.buffer.clearAndFree(); + } +}; + test "unknown DCS command" { const testing = std.testing; const alloc = testing.allocator; @@ -382,3 +514,25 @@ test "tmux enter and implicit exit" { try testing.expect(cmd.tmux == .exit); } } + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); + for ("%end 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); + for ("%error 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); +}