From 6e016ea81e5fee07cce9a329581c7d47e6fd2aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 11:38:15 -0800 Subject: [PATCH] terminal: move tmux into folder --- src/terminal/dcs.zig | 4 +- src/terminal/tmux.zig | 602 +--------------------------------- src/terminal/tmux/control.zig | 602 ++++++++++++++++++++++++++++++++++ 3 files changed, 610 insertions(+), 598 deletions(-) create mode 100644 src/terminal/tmux/control.zig diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 447905d24..425325d4a 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -213,7 +213,7 @@ pub const Command = union(enum) { /// Tmux control mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Notification + terminal.tmux.ControlNotification else void, @@ -276,7 +276,7 @@ const State = union(enum) { /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Client + terminal.tmux.ControlParser else void, diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index f1eb178fe..a6538ea50 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,599 +1,9 @@ -//! This file contains the implementation for tmux control mode. See -//! tmux(1) for more information on control mode. Some basics are documented -//! here but this is not meant to be a comprehensive source of protocol -//! documentation. +//! Types and functions related to tmux protocols. -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const oni = @import("oniguruma"); +const control = @import("tmux/control.zig"); +pub const ControlParser = control.Parser; +pub const ControlNotification = control.Notification; -const log = std.log.scoped(.terminal_tmux); - -/// A tmux control mode client. It is expected that the caller establishes -/// the connection in some way (i.e. detects the opening DCS sequence). This -/// just works on a byte stream. -pub const Client = struct { - /// Current state of the client. - state: State = .idle, - - /// The buffer used to store in-progress notifications, output, etc. - buffer: std.Io.Writer.Allocating, - - /// The maximum size in bytes of the buffer. This is used to limit - /// memory usage. If the buffer exceeds this size, the client will - /// enter a broken state (the control mode session will be forcibly - /// exited and future data dropped). - max_bytes: usize = 1024 * 1024, - - const State = enum { - /// 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. When this state is set, - /// the buffer has been deinited and must not be accessed. - broken, - - /// Inside an active notification (started with '%'). - notification, - - /// Inside a begin/end block. - block, - }; - - pub fn deinit(self: *Client) void { - // If we're in a broken state, we already deinited - // the buffer, so we don't need to do anything. - if (self.state == .broken) return; - - self.buffer.deinit(); - } - - // Handle a byte of input. - pub fn put(self: *Client, byte: u8) !?Notification { - // If we're in a broken state, just do nothing. - // - // We have to do this check here before we check the buffer, because if - // we're in a broken state then we'd have already deinited the buffer. - if (self.state == .broken) return null; - - if (self.buffer.written().len >= self.max_bytes) { - self.broken(); - return error.OutOfMemory; - } - - switch (self.state) { - // Drop because we're in a broken state. - .broken => return null, - - // 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.buffer.clearRetainingCapacity(); - self.state = .notification; - }, - - // If we're in a notification and its not a newline then - // we accumulate. If it is a newline then we have a - // complete notification we need to parse. - .notification => if (byte == '\n') { - // We have a complete notification, parse it. - return try self.parseNotification(); - }, - - // 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 written = self.buffer.written(); - const idx = if (std.mem.lastIndexOfScalar( - u8, - written, - '\n', - )) |v| v + 1 else 0; - const line = written[idx..]; - - if (std.mem.startsWith(u8, line, "%end") or - std.mem.startsWith(u8, line, "%error")) - { - const err = std.mem.startsWith(u8, line, "%error"); - const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); - - // If it is an error then log it. - if (err) log.warn("tmux control mode error={s}", .{output}); - - // Important: do not clear buffer since the notification - // contains it. - self.state = .idle; - return if (err) .{ .block_err = output } else .{ .block_end = output }; - } - - // Didn't end the block, continue accumulating. - }, - } - - try self.buffer.writer.writeByte(byte); - - return null; - } - - fn parseNotification(self: *Client) !?Notification { - assert(self.state == .notification); - - const line = line: { - var line = self.buffer.written(); - if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - break :line line; - }; - const cmd = cmd: { - const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; - break :cmd line[0..idx]; - }; - - // The notification MUST exist because we guard entering the notification - // state on seeing at least a '%'. - 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.state = .block; - self.buffer.clearRetainingCapacity(); - return null; - } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( - "^%output %([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} line=\"{s}\" err={}", .{ cmd, line, 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 data = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .output = .{ .pane_id = id, .data = data } }; - } 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} line=\"{s}\" err={}", .{ cmd, line, 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 if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { - if (!std.mem.eql(u8, line, "%sessions-changed")) { - log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); - break :cmd; - } - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .sessions_changed = {} }; - } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( - "^%window-add @([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} line=\"{s}\" err={}", .{ cmd, line, 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; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_add = .{ .id = id } }; - } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( - "^%window-renamed @([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} line=\"{s}\" err={}", .{ cmd, line, 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 .{ .window_renamed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { - var re = try oni.Regex.init( - "^%window-pane-changed @([0-9]+) %([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} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const window_id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const pane_id = std.fmt.parseInt( - usize, - line[@intCast(starts[2])..@intCast(ends[2])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; - } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { - var re = try oni.Regex.init( - "^%client-detached (.+)$", - .{ .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} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const client = line[@intCast(starts[1])..@intCast(ends[1])]; - - // Important: do not clear buffer here since client points to it - self.state = .idle; - return .{ .client_detached = .{ .client = client } }; - } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { - var re = try oni.Regex.init( - "^%client-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} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const client = line[@intCast(starts[1])..@intCast(ends[1])]; - const session_id = std.fmt.parseInt( - usize, - line[@intCast(starts[2])..@intCast(ends[2])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[3])..@intCast(ends[3])]; - - // Important: do not clear buffer here since client/name point to it - self.state = .idle; - return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; - } else { - // Unknown notification, log it and return to idle state. - log.warn("unknown tmux control mode notification={s}", .{cmd}); - } - - // Unknown command. Clear the buffer and return to idle state. - self.buffer.clearRetainingCapacity(); - self.state = .idle; - - return null; - } - - // Mark the tmux state as broken. - fn broken(self: *Client) void { - self.state = .broken; - self.buffer.deinit(); - } -}; - -/// Possible notification types from tmux control mode. These are documented -/// in tmux(1). A lot of the simple documentation was copied from that man -/// page here. -pub const Notification = union(enum) { - /// Entering tmux control mode. This isn't an actual event sent by - /// tmux but is one sent by us to indicate that we have detected that - /// tmux control mode is starting. - enter, - - /// Exit. - /// - /// NOTE: The tmux protocol contains a "reason" string (human friendly) - /// associated with this. We currently drop it because we don't need it - /// but this may be something we want to add later. If we do add it, - /// we have to consider buffer limits and how we handle those (dropping - /// vs truncating, etc.). - exit, - - /// Dispatched at the end of a begin/end block with the raw data. - /// The control mode parser can't parse the data because it is unaware - /// of the command that was sent to trigger this output. - block_end: []const u8, - block_err: []const u8, - - /// Raw output from a pane. - output: struct { - pane_id: usize, - data: []const u8, // unescaped - }, - - /// The client is now attached to the session with ID session-id, which is - /// named name. - session_changed: struct { - id: usize, - name: []const u8, - }, - - /// A session was created or destroyed. - sessions_changed, - - /// The window with ID window-id was linked to the current session. - window_add: struct { - id: usize, - }, - - /// The window with ID window-id was renamed to name. - window_renamed: struct { - id: usize, - name: []const u8, - }, - - /// The active pane in the window with ID window-id changed to the pane - /// with ID pane-id. - window_pane_changed: struct { - window_id: usize, - pane_id: usize, - }, - - /// The client has detached. - client_detached: struct { - client: []const u8, - }, - - /// The client is now attached to the session with ID session-id, which is - /// named name. - client_session_changed: struct { - client: []const u8, - session_id: usize, - name: []const u8, - }, -}; - -test "tmux begin/end empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("", n.block_end); -} - -test "tmux begin/error empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_err); - try testing.expectEqualStrings("", n.block_err); -} - -test "tmux begin/end data" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("hello\nworld", n.block_end); -} - -test "tmux output" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .output); - try testing.expectEqual(42, n.output.pane_id); - try testing.expectEqualStrings("foo bar baz", n.output.data); -} - -test "tmux session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .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); -} - -test "tmux sessions-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux sessions-changed carriage return" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux window-add" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_add); - try testing.expectEqual(14, n.window_add.id); -} - -test "tmux window-renamed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_renamed); - try testing.expectEqual(42, n.window_renamed.id); - try testing.expectEqualStrings("bar", n.window_renamed.name); -} - -test "tmux window-pane-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_pane_changed); - try testing.expectEqual(42, n.window_pane_changed.window_id); - try testing.expectEqual(2, n.window_pane_changed.pane_id); -} - -test "tmux client-detached" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .client_detached); - try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); -} - -test "tmux client-session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .client_session_changed); - try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); - try testing.expectEqual(2, n.client_session_changed.session_id); - try testing.expectEqualStrings("mysession", n.client_session_changed.name); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig new file mode 100644 index 000000000..8304b2f1f --- /dev/null +++ b/src/terminal/tmux/control.zig @@ -0,0 +1,602 @@ +//! This file contains the implementation for tmux control mode. See +//! tmux(1) for more information on control mode. Some basics are documented +//! here but this is not meant to be a comprehensive source of protocol +//! documentation. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const oni = @import("oniguruma"); + +const log = std.log.scoped(.terminal_tmux); + +/// A tmux control mode parser. This takes in output from tmux control +/// mode and parses it into a structured notifications. +/// +/// It is up to the caller to establish the connection to the tmux +/// control mode session in some way (e.g. via exec, a network socket, +/// whatever). This is fully agnostic to how the data is received and sent. +pub const Parser = struct { + /// Current state of the client. + state: State = .idle, + + /// The buffer used to store in-progress notifications, output, etc. + buffer: std.Io.Writer.Allocating, + + /// The maximum size in bytes of the buffer. This is used to limit + /// memory usage. If the buffer exceeds this size, the client will + /// enter a broken state (the control mode session will be forcibly + /// exited and future data dropped). + max_bytes: usize = 1024 * 1024, + + const State = enum { + /// 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. When this state is set, + /// the buffer has been deinited and must not be accessed. + broken, + + /// Inside an active notification (started with '%'). + notification, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *Parser) void { + // If we're in a broken state, we already deinited + // the buffer, so we don't need to do anything. + if (self.state == .broken) return; + + self.buffer.deinit(); + } + + // Handle a byte of input. + pub fn put(self: *Parser, byte: u8) !?Notification { + // If we're in a broken state, just do nothing. + // + // We have to do this check here before we check the buffer, because if + // we're in a broken state then we'd have already deinited the buffer. + if (self.state == .broken) return null; + + if (self.buffer.written().len >= self.max_bytes) { + self.broken(); + return error.OutOfMemory; + } + + switch (self.state) { + // Drop because we're in a broken state. + .broken => return null, + + // 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.buffer.clearRetainingCapacity(); + self.state = .notification; + }, + + // If we're in a notification and its not a newline then + // we accumulate. If it is a newline then we have a + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. + return try self.parseNotification(); + }, + + // 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 written = self.buffer.written(); + const idx = if (std.mem.lastIndexOfScalar( + u8, + written, + '\n', + )) |v| v + 1 else 0; + const line = written[idx..]; + + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); + + // If it is an error then log it. + if (err) log.warn("tmux control mode error={s}", .{output}); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + return if (err) .{ .block_err = output } else .{ .block_end = output }; + } + + // Didn't end the block, continue accumulating. + }, + } + + try self.buffer.writer.writeByte(byte); + + return null; + } + + fn parseNotification(self: *Parser) !?Notification { + assert(self.state == .notification); + + const line = line: { + var line = self.buffer.written(); + if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; + break :line line; + }; + const cmd = cmd: { + const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; + break :cmd line[0..idx]; + }; + + // The notification MUST exist because we guard entering the notification + // state on seeing at least a '%'. + 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.state = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else if (std.mem.eql(u8, cmd, "%output")) cmd: { + var re = try oni.Regex.init( + "^%output %([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} line=\"{s}\" err={}", .{ cmd, line, 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 data = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .output = .{ .pane_id = id, .data = data } }; + } 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} line=\"{s}\" err={}", .{ cmd, line, 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 if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { + if (!std.mem.eql(u8, line, "%sessions-changed")) { + log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); + break :cmd; + } + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = try oni.Regex.init( + "^%window-add @([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} line=\"{s}\" err={}", .{ cmd, line, 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; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_add = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { + var re = try oni.Regex.init( + "^%window-renamed @([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} line=\"{s}\" err={}", .{ cmd, line, 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 .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = try oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([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} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = try oni.Regex.init( + "^%client-detached (.+)$", + .{ .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} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = try oni.Regex.init( + "^%client-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} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; + } else { + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); + } + + // Unknown command. Clear the buffer and return to idle state. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *Parser) void { + self.state = .broken; + self.buffer.deinit(); + } +}; + +/// Possible notification types from tmux control mode. These are documented +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. +pub const Notification = union(enum) { + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. + block_end: []const u8, + block_err: []const u8, + + /// Raw output from a pane. + output: struct { + pane_id: usize, + data: []const u8, // unescaped + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + session_changed: struct { + id: usize, + name: []const u8, + }, + + /// A session was created or destroyed. + sessions_changed, + + /// The window with ID window-id was linked to the current session. + window_add: struct { + id: usize, + }, + + /// The window with ID window-id was renamed to name. + window_renamed: struct { + id: usize, + name: []const u8, + }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, +}; + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("", n.block_end); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("", n.block_err); +} + +test "tmux begin/end data" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("hello\nworld", n.block_end); +} + +test "tmux output" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(42, n.output.pane_id); + try testing.expectEqualStrings("foo bar baz", n.output.data); +} + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .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); +} + +test "tmux sessions-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux sessions-changed carriage return" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux window-add" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_add); + try testing.expectEqual(14, n.window_add.id); +} + +test "tmux window-renamed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_renamed); + try testing.expectEqual(42, n.window_renamed.id); + try testing.expectEqualStrings("bar", n.window_renamed.name); +} + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +}