ghostty/src/terminal/tmux.zig
2024-07-12 14:04:56 -07:00

424 lines
15 KiB
Zig

//! This file contains the implementation for tmux control mode. See
//! tmux(1) for more information on control mode. Some basics are documented
//! here but this is not meant to be a comprehensive source of protocol
//! documentation.
const std = @import("std");
const assert = std.debug.assert;
const oni = @import("oniguruma");
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.ArrayList(u8),
/// 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.
broken,
/// Inside an active notification (started with '%').
notification,
/// Inside a begin/end block.
block,
};
pub fn deinit(self: *Client) void {
self.buffer.deinit();
}
// Handle a byte of input.
pub fn put(self: *Client, byte: u8) !?Notification {
if (self.buffer.items.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 idx = if (std.mem.lastIndexOfScalar(
u8,
self.buffer.items,
'\n',
)) |v| v + 1 else 0;
const line = self.buffer.items[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, self.buffer.items[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.append(byte);
return null;
}
fn parseNotification(self: *Client) !?Notification {
assert(self.state == .notification);
const line = line: {
var line = self.buffer.items;
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.clearAndFree();
}
};
/// 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 = std.ArrayList(u8).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 = std.ArrayList(u8).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 = std.ArrayList(u8).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 = std.ArrayList(u8).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 = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .session_changed);
try testing.expectEqual(42, n.session_changed.id);
try testing.expectEqualStrings("foo", n.session_changed.name);
}
test "tmux sessions-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).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 = std.ArrayList(u8).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 = std.ArrayList(u8).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 = std.ArrayList(u8).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);
}