terminal/tmux: a lot more control mode parsing, functionality (#9803)

Continuing just build foundation for #1935. There are no user-facing
changes here.

- This adds more command parsing for control mode output. I _believe_ we
now parse enough to at least render everything initially for most tmux
sessions. At least, from the VT parsing side (we haven't done any GUI
work whatsoever to react to this).

- This also adds a new layout string parser, e.g.
`159x48,0,0{79x48,0,0,79x48,80,0}` into a useful struct. We'll need this
to convert our visible layouts into actual GUI components, eventually.

- This adds a new output format parser for commands such as
`list-windows`. Control mode sends output framed in `%begin/%end`
blocks, but the output is in an arbitrary format depending on the input
command and unrelated to control mode itself. The output format parser
will let us parse this output.

I think this is a good place to stop and PR. I think next up I might try
hooking up a state machine to `src/termio/stream_handler.zig` to start
actually working with this. Before that, it may behoove me to change
`stream_readonly` to support non-readonly operations via callback so I
can do DCS and maybe unit test this whole thing too.... we'll see where
the wind blows.

**AI disclosure:** AI wrote many tests and did a lot of implementation
here. I reviewed everything manually though and understand it all
completely.
pull/9813/head
Mitchell Hashimoto 2025-12-04 15:30:13 -08:00 committed by GitHub
commit 3f241502c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1563 additions and 448 deletions

View File

@ -26,7 +26,7 @@ pub const Handler = struct {
assert(self.state == .inactive);
// Initialize our state to ignore in case of error
self.state = .{ .ignore = {} };
self.state = .ignore;
// Try to parse the hook.
const hk_ = self.tryHook(alloc, dcs) catch |err| {
@ -70,7 +70,7 @@ pub const Handler = struct {
),
},
},
.command = .{ .tmux = .{ .enter = {} } },
.command = .{ .tmux = .enter },
};
},
@ -116,7 +116,7 @@ pub const Handler = struct {
// 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 = {} };
self.state = .ignore;
return null;
};
}
@ -158,7 +158,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.
defer self.state = .{ .inactive = {} };
defer self.state = .inactive;
return switch (self.state) {
.inactive,
@ -167,7 +167,7 @@ pub const Handler = struct {
.tmux => if (comptime build_options.tmux_control_mode) tmux: {
self.state.deinit();
break :tmux .{ .tmux = .{ .exit = {} } };
break :tmux .{ .tmux = .exit };
} else unreachable,
.xtgettcap => |*list| xtgettcap: {
@ -200,7 +200,7 @@ pub const Handler = struct {
fn discard(self: *Handler) void {
self.state.deinit();
self.state = .{ .inactive = {} };
self.state = .inactive;
}
};
@ -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,
@ -255,21 +255,15 @@ pub const Command = union(enum) {
decstbm,
decslrm,
};
/// Tmux control mode
pub const Tmux = union(enum) {
enter: void,
exit: void,
};
};
const State = union(enum) {
/// We're not in a DCS state at the moment.
inactive: void,
inactive,
/// We're hooked, but its an unknown DCS command or one that went
/// invalid due to some bad input, so we're ignoring the rest.
ignore: void,
ignore,
/// XTGETTCAP
xtgettcap: std.Io.Writer.Allocating,
@ -282,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,

View File

@ -1,435 +1,12 @@
//! 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");
const layout = @import("tmux/layout.zig");
pub const output = @import("tmux/output.zig");
pub const ControlParser = control.Parser;
pub const ControlNotification = control.Notification;
pub const Layout = layout.Layout;
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 {
// 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).
pub const Notification = union(enum) {
enter: void,
exit: void,
block_end: []const u8,
block_err: []const u8,
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
session_changed: struct {
id: usize,
name: []const u8,
},
sessions_changed: void,
window_add: struct {
id: usize,
},
window_renamed: struct {
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 {
@import("std").testing.refAllDecls(@This());
}

View File

@ -0,0 +1,701 @@
//! 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 (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.
},
}
self.buffer.writer.writeByte(byte) catch |err| switch (err) {
error.WriteFailed => return error.OutOfMemory,
};
return null;
}
const ParseError = error{RegexError};
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,
},
};
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 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);
}

View File

@ -0,0 +1,638 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
/// A tmux layout.
///
/// This is a tree structure so by definition it pretty much needs to be
/// allocated. We leave allocation up to the user of this struct, but
/// a general recommendation is to use an arena allocator for simplicity
/// in freeing the entire layout at once.
pub const Layout = struct {
/// Width, height of the node
width: usize,
height: usize,
/// X and Y offset from the top-left corner of the window.
x: usize,
y: usize,
/// The content of this node, either a pane (leaf) or more nodes
/// (split) horizontally or vertically.
content: Content,
pub const Content = union(enum) {
pane: usize,
horizontal: []const Layout,
vertical: []const Layout,
};
pub const ParseError = Allocator.Error || error{SyntaxError};
/// Parse a layout string that includes a 4-character checksum prefix.
///
/// The expected format is: `XXXX,layout_string` where XXXX is the
/// 4-character hexadecimal checksum and the layout string follows
/// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`.
///
/// Returns `ChecksumMismatch` if the checksum doesn't match the layout.
/// Returns `SyntaxError` if the format is invalid.
pub fn parseWithChecksum(
alloc: Allocator,
str: []const u8,
) (ParseError || error{ChecksumMismatch})!Layout {
// If the string is less than 5 characters, it can't possibly
// be correct. 4-char checksum + comma. In practice it should
// be even longer, but that'll fail parse later.
if (str.len < 5) return error.SyntaxError;
if (str[4] != ',') return error.SyntaxError;
// The layout string should start with a 4-character checksum.
const checksum: Checksum = .calculate(str[5..]);
if (!std.mem.startsWith(
u8,
str,
&checksum.asString(),
)) return error.ChecksumMismatch;
// Checksum matches, parse the rest.
return try parse(alloc, str[5..]);
}
/// Parse a layout string into a Layout structure. The given allocator
/// will be used for all allocations within the layout. Note that
/// individual nodes can't be freed so this allocator must be some
/// kind of arena allocator.
///
/// The layout string must be fully provided as a single string.
/// Layouts are generally small so this should not be a problem.
///
/// Tmux layout strings have the following format:
///
/// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID
/// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated
/// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated
pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout {
var offset: usize = 0;
const root = try parseNext(
alloc,
str,
&offset,
);
if (offset != str.len) return error.SyntaxError;
return root;
}
fn parseNext(
alloc: Allocator,
str: []const u8,
offset: *usize,
) ParseError!Layout {
// Find the first `x` to grab the width.
const width: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
'x',
)) |idx| width: {
defer offset.* += idx + 1; // Consume `x`
break :width std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find the height, up to a comma.
const height: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
',',
)) |idx| height: {
defer offset.* += idx + 1; // Consume `,`
break :height std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find X
const x: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
',',
)) |idx| x: {
defer offset.* += idx + 1; // Consume `,`
break :x std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find Y, which can end in any of `,{,[`
const y: usize = if (std.mem.indexOfAny(
u8,
str[offset.*..],
",{[",
)) |idx| y: {
defer offset.* += idx; // Don't consume the delimiter!
break :y std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Determine our child node.
const content: Layout.Content = switch (str[offset.*]) {
',' => content: {
// Consume the delimiter
offset.* += 1;
// Leaf pane. Read up to `,}]` because we may be in
// a set of nodes. If none exist, end of string is fine.
const idx = std.mem.indexOfAny(
u8,
str[offset.*..],
",}]",
) orelse str.len - offset.*;
defer offset.* += idx; // Consume the pane ID, not the delimiter
const pane_id = std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
break :content .{ .pane = pane_id };
},
'{', '[' => |opening| content: {
var nodes: std.ArrayList(Layout) = .empty;
defer nodes.deinit(alloc);
// Move beyond our opening
offset.* += 1;
while (true) {
try nodes.append(alloc, try parseNext(
alloc,
str,
offset,
));
// We should not reach the end of string here because
// we expect a closing bracket.
if (offset.* >= str.len) return error.SyntaxError;
// If it is a comma, we expect another node.
if (str[offset.*] == ',') {
offset.* += 1; // Consume
continue;
}
// We expect a closing bracket now.
switch (opening) {
'{' => if (str[offset.*] != '}') return error.SyntaxError,
'[' => if (str[offset.*] != ']') return error.SyntaxError,
else => return error.SyntaxError,
}
// Successfully parsed all children.
offset.* += 1; // Consume closing bracket
break :content switch (opening) {
'{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) },
'[' => .{ .vertical = try nodes.toOwnedSlice(alloc) },
else => unreachable,
};
}
},
// indexOfAny above guarantees we have only the above
else => unreachable,
};
return .{
.width = width,
.height = height,
.x = x,
.y = y,
.content = content,
};
}
};
pub const Checksum = enum(u16) {
_,
/// Calculate the checksum of a tmux layout string.
/// The algorithm rotates the checksum right by 1 bit (with wraparound)
/// and adds the ASCII value of each character.
pub fn calculate(str: []const u8) Checksum {
var result: u16 = 0;
for (str) |c| {
// Rotate right by 1: (result >> 1) + ((result & 1) << 15)
result = (result >> 1) | ((result & 1) << 15);
result +%= c;
}
return @enumFromInt(result);
}
/// Convert the checksum to a 4-character hexadecimal string. This
/// is always zero-padded to match the tmux implementation
/// (in layout-custom.c).
pub fn asString(self: Checksum) [4]u8 {
const value = @intFromEnum(self);
const charset = "0123456789abcdef";
return .{
charset[(value >> 12) & 0xf],
charset[(value >> 8) & 0xf],
charset[(value >> 4) & 0xf],
charset[value & 0xf],
};
}
};
test "simple single pane" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
try testing.expectEqual(42, layout.content.pane);
}
test "single pane with offset" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7");
try testing.expectEqual(40, layout.width);
try testing.expectEqual(12, layout.height);
try testing.expectEqual(10, layout.x);
try testing.expectEqual(5, layout.y);
try testing.expectEqual(7, layout.content.pane);
}
test "single pane large values" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999");
try testing.expectEqual(1920, layout.width);
try testing.expectEqual(1080, layout.height);
try testing.expectEqual(100, layout.x);
try testing.expectEqual(200, layout.y);
try testing.expectEqual(999, layout.content.pane);
}
test "horizontal split two panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
const children = layout.content.horizontal;
try testing.expectEqual(2, children.len);
try testing.expectEqual(40, children[0].width);
try testing.expectEqual(24, children[0].height);
try testing.expectEqual(0, children[0].x);
try testing.expectEqual(0, children[0].y);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(40, children[1].width);
try testing.expectEqual(24, children[1].height);
try testing.expectEqual(40, children[1].x);
try testing.expectEqual(0, children[1].y);
try testing.expectEqual(2, children[1].content.pane);
}
test "vertical split two panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
const children = layout.content.vertical;
try testing.expectEqual(2, children.len);
try testing.expectEqual(80, children[0].width);
try testing.expectEqual(12, children[0].height);
try testing.expectEqual(0, children[0].x);
try testing.expectEqual(0, children[0].y);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(80, children[1].width);
try testing.expectEqual(12, children[1].height);
try testing.expectEqual(0, children[1].x);
try testing.expectEqual(12, children[1].y);
try testing.expectEqual(2, children[1].content.pane);
}
test "horizontal split three panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}");
try testing.expectEqual(120, layout.width);
try testing.expectEqual(24, layout.height);
const children = layout.content.horizontal;
try testing.expectEqual(3, children.len);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(2, children[1].content.pane);
try testing.expectEqual(3, children[2].content.pane);
}
test "nested horizontal in vertical" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Vertical split with top pane and bottom horizontal split
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
const vert_children = layout.content.vertical;
try testing.expectEqual(2, vert_children.len);
// First child is a simple pane
try testing.expectEqual(1, vert_children[0].content.pane);
// Second child is a horizontal split
const horiz_children = vert_children[1].content.horizontal;
try testing.expectEqual(2, horiz_children.len);
try testing.expectEqual(2, horiz_children[0].content.pane);
try testing.expectEqual(3, horiz_children[1].content.pane);
}
test "nested vertical in horizontal" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Horizontal split with left pane and right vertical split
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
const horiz_children = layout.content.horizontal;
try testing.expectEqual(2, horiz_children.len);
// First child is a simple pane
try testing.expectEqual(1, horiz_children[0].content.pane);
// Second child is a vertical split
const vert_children = horiz_children[1].content.vertical;
try testing.expectEqual(2, vert_children.len);
try testing.expectEqual(2, vert_children[0].content.pane);
try testing.expectEqual(3, vert_children[1].content.pane);
}
test "deeply nested layout" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Three levels deep
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}");
const horiz = layout.content.horizontal;
try testing.expectEqual(2, horiz.len);
const vert = horiz[0].content.vertical;
try testing.expectEqual(2, vert.len);
try testing.expectEqual(1, vert[0].content.pane);
try testing.expectEqual(2, vert[1].content.pane);
try testing.expectEqual(3, horiz[1].content.pane);
}
test "syntax error empty string" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), ""));
}
test "syntax error missing width" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1"));
}
test "syntax error missing height" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1"));
}
test "syntax error missing x" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1"));
}
test "syntax error missing y" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1"));
}
test "syntax error missing pane id" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,"));
}
test "syntax error non-numeric width" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1"));
}
test "syntax error non-numeric pane id" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc"));
}
test "syntax error unclosed horizontal bracket" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1"));
}
test "syntax error unclosed vertical bracket" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1"));
}
test "syntax error mismatched brackets" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]"));
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}"));
}
test "syntax error trailing data" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra"));
}
test "syntax error no x separator" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1"));
}
test "syntax error no content delimiter" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0"));
}
// parseWithChecksum tests
test "parseWithChecksum valid" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
}
test "parseWithChecksum mismatch" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"));
}
test "parseWithChecksum too short" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62"));
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), ""));
}
test "parseWithChecksum missing comma" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0"));
}
// Checksum tests
test "checksum empty string" {
const checksum = Checksum.calculate("");
try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum));
try testing.expectEqualStrings("0000", &checksum.asString());
}
test "checksum single character" {
// 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65
const checksum = Checksum.calculate("A");
try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum));
try testing.expectEqualStrings("0041", &checksum.asString());
}
test "checksum two characters" {
// 'A' (65): csum = 0, rotate = 0, add 65 => 65
// 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800
// add 66 => 32800 + 66 = 32866
const checksum = Checksum.calculate("AB");
try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum));
try testing.expectEqualStrings("8062", &checksum.asString());
}
test "checksum simple layout" {
const checksum = Checksum.calculate("80x24,0,0,42");
try testing.expectEqualStrings("d962", &checksum.asString());
}
test "checksum horizontal split layout" {
const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqualStrings("f8f9", &checksum.asString());
}
test "checksum asString zero padding" {
// Value 0x000f should produce "000f"
const checksum: Checksum = @enumFromInt(0x000f);
try testing.expectEqualStrings("000f", &checksum.asString());
}
test "checksum asString all digits" {
// Value 0x1234 should produce "1234"
const checksum: Checksum = @enumFromInt(0x1234);
try testing.expectEqualStrings("1234", &checksum.asString());
}
test "checksum asString with letters" {
// Value 0xabcd should produce "abcd"
const checksum: Checksum = @enumFromInt(0xabcd);
try testing.expectEqualStrings("abcd", &checksum.asString());
}
test "checksum asString max value" {
// Value 0xffff should produce "ffff"
const checksum: Checksum = @enumFromInt(0xffff);
try testing.expectEqualStrings("ffff", &checksum.asString());
}
test "checksum wraparound" {
const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff");
try testing.expectEqualStrings("03fc", &checksum.asString());
}
test "checksum deterministic" {
// Same input should always produce same output
const str = "159x48,0,0{79x48,0,0,79x48,80,0}";
const checksum1 = Checksum.calculate(str);
const checksum2 = Checksum.calculate(str);
try testing.expectEqual(checksum1, checksum2);
}
test "checksum different inputs different outputs" {
const checksum1 = Checksum.calculate("80x24,0,0,1");
const checksum2 = Checksum.calculate("80x24,0,0,2");
try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2));
}
test "checksum known tmux layout bb62" {
// From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}"
// The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}"
const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}");
try testing.expectEqualStrings("bb62", &checksum.asString());
}

View File

@ -0,0 +1,205 @@
const std = @import("std");
const testing = std.testing;
pub const ParseError = error{
MissingEntry,
ExtraEntry,
FormatError,
};
/// Parse the output from a command with the given format struct
/// (returned usually by FormatStruct). The format struct is expected
/// to be in the order of the variables used in the format string and
/// the variables are expected to be plain variables (no conditionals,
/// extra formatting, etc.). Each variable is expected to be separated
/// by a single `delimiter` character.
pub fn parseFormatStruct(
comptime T: type,
str: []const u8,
delimiter: u8,
) ParseError!T {
// Parse all our fields
const fields = @typeInfo(T).@"struct".fields;
var it = std.mem.splitScalar(u8, str, delimiter);
var result: T = undefined;
inline for (fields) |field| {
const part = it.next() orelse return error.MissingEntry;
@field(result, field.name) = Variable.parse(
@field(Variable, field.name),
part,
) catch return error.FormatError;
}
// We should have consumed all parts now.
if (it.next() != null) return error.ExtraEntry;
return result;
}
/// Returns a struct type that contains fields for each of the given
/// format variables. This can be used with `parseFormatStruct` to
/// parse an output string into a format struct.
pub fn FormatStruct(comptime vars: []const Variable) type {
var fields: [vars.len]std.builtin.Type.StructField = undefined;
for (vars, &fields) |variable, *field| {
field.* = .{
.name = @tagName(variable),
.type = variable.Type(),
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(variable.Type()),
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.fields = &fields,
.decls = &.{},
.is_tuple = false,
} });
}
/// Possible variables in a tmux format string that we support.
///
/// Tmux supports a large number of variables, but we only implement
/// a subset of them here that are relevant to the use case of implementing
/// control mode for terminal emulators.
pub const Variable = enum {
session_id,
window_id,
window_width,
window_height,
window_layout,
/// Parse the given string value into the appropriate resulting
/// type for this variable.
pub fn parse(comptime self: Variable, value: []const u8) !Type(self) {
return switch (self) {
.session_id => if (value.len >= 2 and value[0] == '$')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_id => if (value.len >= 2 and value[0] == '@')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_width => try std.fmt.parseInt(usize, value, 10),
.window_height => try std.fmt.parseInt(usize, value, 10),
.window_layout => value,
};
}
/// The type of the parsed value for this variable type.
pub fn Type(comptime self: Variable) type {
return switch (self) {
.session_id => usize,
.window_id => usize,
.window_width => usize,
.window_height => usize,
.window_layout => []const u8,
};
}
};
test "parse session id" {
try testing.expectEqual(42, try Variable.parse(.session_id, "$42"));
try testing.expectEqual(0, try Variable.parse(.session_id, "$0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "$"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc"));
}
test "parse window id" {
try testing.expectEqual(42, try Variable.parse(.window_id, "@42"));
try testing.expectEqual(0, try Variable.parse(.window_id, "@0"));
try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "@"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc"));
}
test "parse window width" {
try testing.expectEqual(80, try Variable.parse(.window_width, "80"));
try testing.expectEqual(0, try Variable.parse(.window_width, "0"));
try testing.expectEqual(12345, try Variable.parse(.window_width, "12345"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px"));
try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1"));
}
test "parse window height" {
try testing.expectEqual(24, try Variable.parse(.window_height, "24"));
try testing.expectEqual(0, try Variable.parse(.window_height, "0"));
try testing.expectEqual(12345, try Variable.parse(.window_height, "12345"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px"));
try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1"));
}
test "parse window layout" {
try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123"));
try testing.expectEqualStrings("", try Variable.parse(.window_layout, ""));
try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)"));
}
test "parseFormatStruct single field" {
const T = FormatStruct(&.{.session_id});
const result = try parseFormatStruct(T, "$42", ' ');
try testing.expectEqual(42, result.session_id);
}
test "parseFormatStruct multiple fields" {
const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height });
const result = try parseFormatStruct(T, "$1 @2 80 24", ' ');
try testing.expectEqual(1, result.session_id);
try testing.expectEqual(2, result.window_id);
try testing.expectEqual(80, result.window_width);
try testing.expectEqual(24, result.window_height);
}
test "parseFormatStruct with string field" {
const T = FormatStruct(&.{ .window_id, .window_layout });
const result = try parseFormatStruct(T, "@5,abc123", ',');
try testing.expectEqual(5, result.window_id);
try testing.expectEqualStrings("abc123", result.window_layout);
}
test "parseFormatStruct different delimiter" {
const T = FormatStruct(&.{ .window_width, .window_height });
const result = try parseFormatStruct(T, "120\t40", '\t');
try testing.expectEqual(120, result.window_width);
try testing.expectEqual(40, result.window_height);
}
test "parseFormatStruct missing entry" {
const T = FormatStruct(&.{ .session_id, .window_id });
try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' '));
}
test "parseFormatStruct extra entry" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' '));
}
test "parseFormatStruct format error" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' '));
try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' '));
try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' '));
}
test "parseFormatStruct empty string" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' '));
}
test "parseFormatStruct with empty layout field" {
const T = FormatStruct(&.{ .session_id, .window_layout });
const result = try parseFormatStruct(T, "$1,", ',');
try testing.expectEqual(1, result.session_id);
try testing.expectEqualStrings("", result.window_layout);
}