diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/tmux/control.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/tmux/control.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/tmux/control.zig | 839 |
1 files changed, 839 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/tmux/control.zig b/vendor/libghostty-vt/src/terminal/tmux/control.zig new file mode 100644 index 00000000..3b94eb16 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/tmux/control.zig @@ -0,0 +1,839 @@ +//! 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 Allocator = std.mem.Allocator; +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. + // + // If we reach our byte limit this will return OutOfMemory. It only + // does this on the first time we exceed the limit; subsequent calls + // will return null as we drop all input in a broken state. + pub fn put(self: *Parser, byte: u8) Allocator.Error!?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 self.parseNotification() catch { + // If parsing failed, then we do not mark the state + // as broken because we may be able to continue parsing + // other types of notifications. + // + // In the future we may want to emit a notification + // here about unknown or unsupported notifications. + return null; + }; + }, + + // 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 (parseBlockTerminator(line)) |terminator| { + const output = std.mem.trimRight( + u8, + written[0..idx], + "\r\n", + ); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + switch (terminator) { + .end => return .{ .block_end = output }, + .err => { + log.warn("tmux control mode error={s}", .{output}); + return .{ .block_err = output }; + }, + } + } + + // Didn't end the block, continue accumulating. + }, + } + + self.buffer.writer.writeByte(byte) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + }; + + return null; + } + + const ParseError = error{RegexError}; + + const BlockTerminator = enum { end, err }; + + /// Block payload is raw data, so a line only terminates a block if it + /// exactly matches tmux's `%end`/`%error` guard-line shape. + fn parseBlockTerminator(line_raw: []const u8) ?BlockTerminator { + var line = line_raw; + if (line.len > 0 and line[line.len - 1] == '\r') { + line = line[0 .. line.len - 1]; + } + + var fields = std.mem.tokenizeScalar(u8, line, ' '); + const cmd = fields.next() orelse return null; + const terminator: BlockTerminator = if (std.mem.eql(u8, cmd, "%end")) + .end + else if (std.mem.eql(u8, cmd, "%error")) + .err + else + return null; + + const time = fields.next() orelse return null; + const command_id = fields.next() orelse return null; + const flags = fields.next() orelse return null; + const extra = fields.next(); + + // In the future, we should compare these to the %begin block + // because the tmux source guarantees that these always match and + // that is a more robust way to match. + _ = std.fmt.parseInt(usize, time, 10) catch return null; + _ = std.fmt.parseInt(usize, command_id, 10) catch return null; + _ = std.fmt.parseInt(usize, flags, 10) catch return null; + if (extra != null) return null; + + return terminator; + } + + fn parseNotification(self: *Parser) ParseError!?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 = oni.Regex.init( + "^%output %([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 = oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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, "%layout-change")) cmd: { + var re = oni.Regex.init( + "^%layout-change @([0-9]+) (.+) (.+) (.*)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 layout = line[@intCast(starts[2])..@intCast(ends[2])]; + const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])]; + const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])]; + + // Important: do not clear buffer here since layout strings point to it + self.state = .idle; + return .{ .layout_change = .{ + .window_id = id, + .layout = layout, + .visible_layout = visible_layout, + .raw_flags = raw_flags, + } }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = oni.Regex.init( + "^%window-add @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 = oni.Regex.init( + "^%window-renamed @([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 = oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 = oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 = oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + 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 layout of the window with ID window-id changed. + layout_change: struct { + window_id: usize, + layout: []const u8, + visible_layout: []const u8, + raw_flags: []const u8, + }, + + /// 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, + }, + + pub fn format(self: Notification, writer: *std.Io.Writer) !void { + const T = Notification; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } +}; + +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 block payload may start with %end" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end not really\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 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("%end not really\nhello", n.block_end); +} + +test "tmux block payload may start with %error" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 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("%error not really\nhello", n.block_end); +} + +test "tmux block may terminate with real %error after misleading payload" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1 1 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("%error not really\nhello", n.block_err); +} + +test "tmux block terminator requires exact token count" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 1 trailing\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 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("%end 1 1 1 trailing\nhello", n.block_end); +} + +test "tmux block terminator requires numeric metadata" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end foo bar baz\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 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("%end foo bar baz\nhello", 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 layout-change" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .layout_change); + try testing.expectEqual(2, n.layout_change.window_id); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout); + try testing.expectEqualStrings("*-", n.layout_change.raw_flags); +} + +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); +} |
