From bc7bc151209151f58013c1afbf697a9dd408a53f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Jul 2024 10:22:59 -0700 Subject: [PATCH] terminal/tmux: parse session-changed notification --- src/terminal/tmux.zig | 94 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 27f28e77a..4e7f92b5e 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -5,6 +5,7 @@ const std = @import("std"); const assert = std.debug.assert; +const oni = @import("oniguruma"); const log = std.log.scoped(.terminal_tmux); @@ -15,7 +16,7 @@ pub const Client = struct { /// Current state of the client. state: State = .idle, - /// The buffer used to store in-progress commands, output, etc. + /// The buffer used to store in-progress notifications, output, etc. buffer: std.ArrayList(u8), /// The maximum size in bytes of the buffer. This is used to limit @@ -25,16 +26,18 @@ pub const Client = struct { max_bytes: usize = 1024 * 1024, const State = enum { - /// Outside of any active command. This should drop any output - /// unless it is '%' on the first byte of a line. + /// Outside of any active notifications. This should drop any output + /// unless it is '%' on the first byte of a line. The buffer will be + /// cleared when it sees '%', this is so that the previous notification + /// data is valid until we receive/process new data. 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 an active notification (started with '%'). + notification, /// Inside a begin/end block. block, @@ -55,24 +58,27 @@ pub const Client = struct { // 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. + // Waiting for a notification so if the byte is not '%' then + // we're in a broken state. Control mode output should always + // be wrapped in '%begin/%end' orelse we expect a notification. + // Return an exit notification. .idle => if (byte != '%') { self.broken(); return .{ .exit = {} }; } else { - self.state = .command; + self.buffer.clearRetainingCapacity(); + self.state = .notification; }, - // If we're in a command and its not a newline then + // If we're in a notification 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. + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. return try self.parseNotification(); }, - // If we're ina block then we accumulate until we see a newline + // If we're in a 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( @@ -81,6 +87,7 @@ pub const Client = struct { '\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")) { @@ -95,6 +102,8 @@ pub const Client = struct { self.buffer.clearRetainingCapacity(); return null; } + + // Didn't end the block, continue accumulating. }, } @@ -104,11 +113,12 @@ pub const Client = struct { } fn parseNotification(self: *Client) !?Notification { - assert(self.state == .command); + assert(self.state == .notification); - var it = std.mem.tokenizeScalar(u8, self.buffer.items, ' '); + const line = self.buffer.items; + var it = std.mem.tokenizeScalar(u8, line, ' '); - // The command MUST exist because we guard entering the command + // The notification MUST exist because we guard entering the notification // state on seeing at least a '%'. const cmd = it.next().?; if (std.mem.eql(u8, cmd, "%begin")) { @@ -123,12 +133,40 @@ pub const Client = struct { self.state = .block; self.buffer.clearRetainingCapacity(); return null; + } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { + var re = try oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} err={}", .{ cmd, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .session_changed = .{ .id = id, .name = name } }; } else { - // Unknown command, log it and return to idle state. - log.warn("unknown tmux control mode command={s}", .{cmd}); + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); } - // Successful exit, revert to idle state. + // Unknown command. Clear the buffer and return to idle state. self.buffer.clearRetainingCapacity(); self.state = .idle; @@ -147,6 +185,11 @@ pub const Client = struct { pub const Notification = union(enum) { enter: void, exit: void, + + session_changed: struct { + id: usize, + name: []const u8, + }, }; test "tmux begin/end empty" { @@ -168,3 +211,16 @@ test "tmux begin/error empty" { for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); for ("%error 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); } + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_changed); + try testing.expectEqual(42, n.session_changed.id); + try testing.expectEqualStrings("foo", n.session_changed.name); +}