terminal: OSC parser

This commit is contained in:
Mitchell Hashimoto
2022-05-10 09:27:29 -07:00
parent 1045c40489
commit bb4332ac38
4 changed files with 169 additions and 6 deletions

View File

@ -7,6 +7,7 @@ const Parser = @This();
const std = @import("std");
const testing = std.testing;
const table = @import("parse_table.zig").table;
const osc = @import("osc.zig");
const log = std.log.scoped(.parser);
@ -66,6 +67,9 @@ pub const Action = union(enum) {
/// Execute the ESC command.
esc_dispatch: ESC,
/// Execute the OSC command.
osc_dispatch: osc.Command,
pub const CSI = struct {
intermediates: []u8,
params: []u16,
@ -95,6 +99,9 @@ params_idx: u8 = 0,
param_acc: u16 = 0,
param_acc_idx: u8 = 0,
/// Parser for OSC sequences
osc_parser: osc.Parser = .{},
pub fn init() Parser {
return .{};
}
@ -126,20 +133,28 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
// 2. transition action
// 3. entry action to new state
return [3]?Action{
switch (self.state) {
.osc_string => @panic("TODO"), // TODO: osc_end
// Exit depends on current state
if (self.state == next_state) null else switch (self.state) {
.osc_string => if (self.osc_parser.end()) |cmd|
Action{ .osc_dispatch = cmd }
else
null,
.dcs_passthrough => @panic("TODO"), // TODO: unhook
else => null,
},
self.doAction(action, c),
switch (next_state) {
// Entry depends on new state
if (self.state == next_state) null else switch (next_state) {
.escape, .dcs_entry, .csi_entry => clear: {
self.clear();
break :clear null;
},
.osc_string => @panic("TODO"), // TODO: osc_start
.osc_string => osc_string: {
self.osc_parser.reset();
break :osc_string null;
},
.dcs_passthrough => @panic("TODO"), // TODO: hook
else => null,
},
@ -147,7 +162,6 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
}
fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
_ = self;
return switch (action) {
.none, .ignore => null,
.print => Action{ .print = c },
@ -191,6 +205,10 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
// The client is expected to perform no action.
break :param null;
},
.osc_put => osc_put: {
self.osc_parser.next(c);
break :osc_put null;
},
.csi_dispatch => csi_dispatch: {
// Finalize parameters if we have one
if (self.param_acc_idx > 0) {
@ -309,3 +327,25 @@ test "csi: ESC [ 1 ; 4 H" {
try testing.expectEqual(@as(u16, 4), d.params[1]);
}
}
test "osc: change window title" {
var p = init();
_ = p.next(0x1B);
_ = p.next(']');
_ = p.next('0');
_ = p.next(';');
_ = p.next('a');
_ = p.next('b');
_ = p.next('c');
{
const a = p.next(0x07); // BEL
try testing.expect(p.state == .ground);
try testing.expect(a[0].? == .osc_dispatch);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
const cmd = a[0].?.osc_dispatch;
try testing.expect(cmd == .change_window_title);
}
}

View File

@ -137,6 +137,7 @@ pub fn appendChar(self: *Terminal, alloc: Allocator, c: u8) !void {
.execute => |code| try self.execute(alloc, code),
.csi_dispatch => |csi| try self.csiDispatch(alloc, csi),
.esc_dispatch => |esc| try self.escDispatch(alloc, esc),
.osc_dispatch => |cmd| log.warn("unhandled OSC: {}", .{cmd}),
}
}
}
@ -626,6 +627,7 @@ fn getOrPutCell(self: *Terminal, alloc: Allocator, x: usize, y: usize) !*Cell {
}
test {
_ = @import("osc.zig");
_ = Parser;
_ = Tabstops;
}

119
src/terminal/osc.zig Normal file
View File

@ -0,0 +1,119 @@
//! OSC (Operating System Command) related functions and types. OSC is
//! another set of control sequences for terminal programs that start with
//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings
//! and other irregular formatting so a dedicated parser is created to handle it.
const osc = @This();
const std = @import("std");
const log = std.log.scoped(.osc);
pub const Command = union(enum) {
/// Set the window title of the terminal
///
/// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
/// with each code unit further encoded with two hex digets).
///
/// If title mode 2 is set or the terminal is setup for unconditional
/// utf-8 titles text is interpreted as utf-8. Else text is interpreted
/// as latin1.
change_window_title: []const u8,
};
pub const Parser = struct {
state: State = .empty,
command: Command = undefined,
param_str: ?*[]const u8 = null,
buf: [MAX_BUF]u8 = undefined,
buf_start: usize = 0,
buf_idx: usize = 0,
complete: bool = false,
// Maximum length of a single OSC command. This is the full OSC command
// sequence length (excluding ESC ]). This is arbitrary, I couldn't find
// any definitive resource on how long this should be.
const MAX_BUF = 2048;
pub const State = enum {
empty,
invalid,
@"0",
string,
};
/// Reset the parser start.
pub fn reset(self: *Parser) void {
self.state = .empty;
self.param_str = null;
self.buf_start = 0;
self.buf_idx = 0;
self.complete = false;
}
/// Consume the next character c and advance the parser state.
pub fn next(self: *Parser, c: u8) void {
// We store everything in the buffer so we can do a better job
// logging if we get to an invalid command.
self.buf[self.buf_idx] = c;
self.buf_idx += 1;
log.info("state = {} c = {x}", .{ self.state, c });
switch (self.state) {
// Ignore, we're in some invalid state and we can't possibly
// do anything reasonable.
.invalid => {},
.empty => switch (c) {
'0' => self.state = .@"0",
else => self.state = .invalid,
},
.@"0" => switch (c) {
';' => {
self.command = .{ .change_window_title = undefined };
self.state = .string;
self.param_str = &self.command.change_window_title;
self.buf_start = self.buf_idx;
},
else => self.state = .invalid,
},
.string => {
// Complete once we receive one character since we have
// at least SOME value for the expected string value.
self.complete = true;
},
}
}
/// End the sequence and return the command, if any. If the return value
/// is null, then no valid command was found.
pub fn end(self: Parser) ?Command {
if (!self.complete) {
log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]});
return null;
}
// If we have an expected string parameter, fill it in.
if (self.param_str) |param_str| {
param_str.* = self.buf[self.buf_start..self.buf_idx];
}
return self.command;
}
};
test "OSC: change_window_title" {
const testing = std.testing;
var p: Parser = .{};
p.next('0');
p.next(';');
p.next('a');
p.next('b');
const cmd = p.end().?;
try testing.expect(cmd == .change_window_title);
try testing.expectEqualStrings("ab", cmd.change_window_title);
}

View File

@ -319,11 +319,13 @@ fn genTable() Table {
// events
single(&result, 0x19, source, source, .ignore);
range(&result, 0, 0x17, source, source, .ignore);
range(&result, 0, 0x06, source, source, .ignore);
range(&result, 0x08, 0x17, source, source, .ignore);
range(&result, 0x1C, 0x1F, source, source, .ignore);
range(&result, 0x20, 0x7F, source, source, .osc_put);
// => ground
single(&result, 0x07, source, .ground, .none);
single(&result, 0x9C, source, .ground, .none);
}