diff --git a/example/c-vt/src/main.c b/example/c-vt/src/main.c index 00ea3618f..b1297d7a7 100644 --- a/example/c-vt/src/main.c +++ b/example/c-vt/src/main.c @@ -1,5 +1,6 @@ #include #include +#include #include int main() { @@ -8,10 +9,13 @@ int main() { return 1; } - // Setup change window title command to change the title to "a" + // Setup change window title command to change the title to "hello" ghostty_osc_next(parser, '0'); ghostty_osc_next(parser, ';'); - ghostty_osc_next(parser, 'a'); + const char *title = "hello"; + for (size_t i = 0; i < strlen(title); i++) { + ghostty_osc_next(parser, title[i]); + } // End parsing and get command GhosttyOscCommand command = ghostty_osc_end(parser, 0); @@ -20,6 +24,13 @@ int main() { GhosttyOscCommandType type = ghostty_osc_command_type(command); printf("Command type: %d\n", type); + // Extract and print the title + if (ghostty_osc_command_data(command, GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR, &title)) { + printf("Extracted title: %s\n", title); + } else { + printf("Failed to extract title\n"); + } + ghostty_osc_free(parser); return 0; } diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 5d80cb653..33ff2a961 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -32,6 +32,8 @@ extern "C" { * be used to parse the contents of OSC sequences. This isn't a full VT * parser; it is only the OSC parser component. This is useful if you have * a parser already and want to only extract and handle OSC sequences. + * + * @ingroup osc */ typedef struct GhosttyOscParser *GhosttyOscParser; @@ -41,6 +43,8 @@ typedef struct GhosttyOscParser *GhosttyOscParser; * This handle represents a parsed OSC (Operating System Command) command. * The command can be queried for its type and associated data using * `ghostty_osc_command_type` and `ghostty_osc_command_data`. + * + * @ingroup osc */ typedef struct GhosttyOscCommand *GhosttyOscCommand; @@ -56,6 +60,8 @@ typedef enum { /** * OSC command types. + * + * @ingroup osc */ typedef enum { GHOSTTY_OSC_COMMAND_INVALID = 0, @@ -81,6 +87,31 @@ typedef enum { GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, } GhosttyOscCommandType; +/** + * OSC command data types. + * + * These values specify what type of data to extract from an OSC command + * using `ghostty_osc_command_data`. + * + * @ingroup osc + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_OSC_DATA_INVALID = 0, + + /** + * Window title string data. + * + * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE + * + * Output type: const char ** (pointer to null-terminated string) + * + * Lifetime: Valid until the next call to any ghostty_osc_* function with + * the same parser instance. Memory is owned by the parser. + */ + GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, +} GhosttyOscCommandData; + //------------------------------------------------------------------- // Allocator Interface @@ -227,6 +258,27 @@ typedef struct { //------------------------------------------------------------------- // Functions +/** @defgroup osc OSC Parser + * + * OSC (Operating System Command) sequence parser and command handling. + * + * The parser operates in a streaming fashion, processing input byte-by-byte + * to handle OSC sequences that may arrive in fragments across multiple reads. + * This interface makes it easy to integrate into most environments and avoids + * over-allocating buffers. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_osc_new() + * 2. Feed bytes to the parser using ghostty_osc_next() + * 3. Finalize parsing with ghostty_osc_end() to get the command + * 4. Query command type and extract data using ghostty_osc_command_type() + * and ghostty_osc_command_data() + * 5. Free the parser with ghostty_osc_free() when done + * + * @{ + */ + /** * Create a new OSC parser instance. * @@ -316,6 +368,23 @@ GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); */ GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); +/** + * Extract data from an OSC command. + * + * Extracts typed data from the given OSC command based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid command types, output types, and memory + * safety information are documented in the `GhosttyOscCommandData` enum. + * + * @param command The OSC command handle to query (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return true if data extraction was successful, false otherwise + */ +bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); + +/** @} */ // end of osc group + #ifdef __cplusplus } #endif diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 5ab9d3cd4..49ab00ecd 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -197,7 +197,9 @@ pub const VTEvent = struct { ) !void { switch (@TypeOf(v)) { void => {}, - []const u8 => try md.put("data", try alloc.dupeZ(u8, v)), + []const u8, + [:0]const u8, + => try md.put("data", try alloc.dupeZ(u8, v)), else => |T| switch (@typeInfo(T)) { .@"struct" => |info| inline for (info.fields) |field| { try encodeMetadataSingle( @@ -284,7 +286,9 @@ pub const VTEvent = struct { try std.fmt.allocPrintZ(alloc, "{}", .{value}), ), - []const u8 => try md.put(key, try alloc.dupeZ(u8, value)), + []const u8, + [:0]const u8, + => try md.put(key, try alloc.dupeZ(u8, value)), else => |T| { @compileLog(T); diff --git a/src/lib_vt.zig b/src/lib_vt.zig index b7ef9459a..763f17f98 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -77,6 +77,7 @@ comptime { @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); + @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); } } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index f32dd226f..68fd77edd 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -7,6 +7,7 @@ pub const osc_reset = osc.reset; pub const osc_next = osc.next; pub const osc_end = osc.end; pub const osc_command_type = osc.commandType; +pub const osc_command_data = osc.commandData; test { _ = osc; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index d1998f4e1..8b6a8409c 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -49,6 +49,51 @@ pub fn commandType(command_: Command) callconv(.c) osc.Command.Key { return command.*; } +/// C: GhosttyOscCommandData +pub const CommandData = enum(c_int) { + invalid = 0, + change_window_title_str = 1, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: CommandData) type { + return switch (self) { + .invalid => void, + .change_window_title_str => [*:0]const u8, + }; + } +}; + +pub fn commandData( + command_: Command, + data: CommandData, + out: ?*anyopaque, +) callconv(.c) bool { + return switch (data) { + inline else => |comptime_data| commandDataTyped( + command_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn commandDataTyped( + command_: Command, + comptime data: CommandData, + out: *data.OutType(), +) bool { + const command = command_.?; + switch (data) { + .invalid => return false, + .change_window_title_str => switch (command.*) { + .change_window_title => |v| out.* = v.ptr, + else => return false, + }, + } + + return true; +} + test "alloc" { const testing = std.testing; var p: Parser = undefined; @@ -64,7 +109,7 @@ test "command type null" { try testing.expectEqual(.invalid, commandType(null)); } -test "command type" { +test "change window title" { const testing = std.testing; var p: Parser = undefined; try testing.expectEqual(Result.success, new( @@ -73,9 +118,15 @@ test "command type" { )); defer free(p); + // Parse it next(p, '0'); next(p, ';'); next(p, 'a'); const cmd = end(p, 0); try testing.expectEqual(.change_window_title, commandType(cmd)); + + // Extract the title + var title: [*:0]const u8 = undefined; + try testing.expect(commandData(cmd, .change_window_title_str, @ptrCast(&title))); + try testing.expectEqualStrings("a", std.mem.span(title)); } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 20b22d1ef..800257c3d 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -26,19 +26,19 @@ pub const Command = union(Key) { /// Set the window title of the terminal /// - /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 + /// 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 digits). /// /// 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, + change_window_title: [:0]const u8, /// Set the icon of the terminal window. The name of the icon is not /// well defined, so this is currently ignored by Ghostty at the time /// of writing this. We just parse it so that we don't get parse errors /// in the log. - change_window_icon: []const u8, + change_window_icon: [:0]const u8, /// First do a fresh-line. Then start a new command, and enter prompt mode: /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a @@ -54,7 +54,7 @@ pub const Command = union(Key) { /// - secondary: a non-editable continuation line /// - right: a right-aligned prompt that may need adjustment during reflow prompt_start: struct { - aid: ?[]const u8 = null, + aid: ?[:0]const u8 = null, kind: enum { primary, continuation, secondary, right } = .primary, redraw: bool = true, }, @@ -96,7 +96,7 @@ pub const Command = union(Key) { /// contents is set on the clipboard. clipboard_contents: struct { kind: u8, - data: []const u8, + data: [:0]const u8, }, /// OSC 7. Reports the current working directory of the shell. This is @@ -106,7 +106,7 @@ pub const Command = union(Key) { report_pwd: struct { /// The reported pwd value. This is not checked for validity. It should /// be a file URL but it is up to the caller to utilize this value. - value: []const u8, + value: [:0]const u8, }, /// OSC 22. Set the mouse shape. There doesn't seem to be a standard @@ -114,7 +114,7 @@ pub const Command = union(Key) { /// are moving towards using the W3C CSS cursor names. For OSC parsing, /// we just parse whatever string is given. mouse_shape: struct { - value: []const u8, + value: [:0]const u8, }, /// OSC color operations to set, reset, or report color settings. Some OSCs @@ -138,14 +138,14 @@ pub const Command = union(Key) { /// Show a desktop notification (OSC 9 or OSC 777) show_desktop_notification: struct { - title: []const u8, - body: []const u8, + title: [:0]const u8, + body: [:0]const u8, }, /// Start a hyperlink (OSC 8) hyperlink_start: struct { - id: ?[]const u8 = null, - uri: []const u8, + id: ?[:0]const u8 = null, + uri: [:0]const u8, }, /// End a hyperlink (OSC 8) @@ -157,12 +157,12 @@ pub const Command = union(Key) { }, /// ConEmu show GUI message box (OSC 9;2) - conemu_show_message_box: []const u8, + conemu_show_message_box: [:0]const u8, /// ConEmu change tab title (OSC 9;3) conemu_change_tab_title: union(enum) { reset, - value: []const u8, + value: [:0]const u8, }, /// ConEmu progress report (OSC 9;4) @@ -172,7 +172,7 @@ pub const Command = union(Key) { conemu_wait_input, /// ConEmu GUI macro (OSC 9;6) - conemu_guimacro: []const u8, + conemu_guimacro: [:0]const u8, pub const Key = LibEnum( if (build_options.c_abi) .c else .zig, @@ -305,7 +305,7 @@ pub const Parser = struct { /// Temporary state that is dependent on the current state. temp_state: union { /// Current string parameter being populated - str: *[]const u8, + str: *[:0]const u8, /// Current numeric parameter being populated num: u16, @@ -498,7 +498,10 @@ pub const Parser = struct { // If our buffer is full then we're invalid, so we set our state // accordingly and indicate the sequence is incomplete so that we // don't accidentally issue a command when ending. - if (self.buf_idx >= self.buf.len) { + // + // We always keep space for 1 byte at the end to null-terminate + // values. + if (self.buf_idx >= self.buf.len - 1) { if (self.state != .invalid) { log.warn( "OSC sequence too long (> {d}), ignoring. state={}", @@ -1037,7 +1040,8 @@ pub const Parser = struct { .notification_title => switch (c) { ';' => { - self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; + self.buf[self.buf_idx - 1] = 0; + self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1 :0]; self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; self.buf_start = self.buf_idx; self.state = .string; @@ -1406,7 +1410,8 @@ pub const Parser = struct { fn endHyperlink(self: *Parser) void { switch (self.command) { .hyperlink_start => |*v| { - const value = self.buf[self.buf_start..self.buf_idx]; + self.buf[self.buf_idx] = 0; + const value = self.buf[self.buf_start..self.buf_idx :0]; if (v.id == null and value.len == 0) { self.command = .{ .hyperlink_end = {} }; return; @@ -1420,10 +1425,12 @@ pub const Parser = struct { } fn endHyperlinkOptionValue(self: *Parser) void { - const value = if (self.buf_start == self.buf_idx) + const value: [:0]const u8 = if (self.buf_start == self.buf_idx) "" - else - self.buf[self.buf_start .. self.buf_idx - 1]; + else buf: { + self.buf[self.buf_idx - 1] = 0; + break :buf self.buf[self.buf_start .. self.buf_idx - 1 :0]; + }; if (mem.eql(u8, self.temp_state.key, "id")) { switch (self.command) { @@ -1438,7 +1445,11 @@ pub const Parser = struct { } fn endSemanticOptionValue(self: *Parser) void { - const value = self.buf[self.buf_start..self.buf_idx]; + const value = value: { + self.buf[self.buf_idx] = 0; + defer self.buf_idx += 1; + break :value self.buf[self.buf_start..self.buf_idx :0]; + }; if (mem.eql(u8, self.temp_state.key, "aid")) { switch (self.command) { @@ -1495,7 +1506,9 @@ pub const Parser = struct { } fn endString(self: *Parser) void { - self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; + self.buf[self.buf_idx] = 0; + defer self.buf_idx += 1; + self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx :0]; } fn endConEmuSleepValue(self: *Parser) void { @@ -1589,8 +1602,15 @@ pub const Parser = struct { } fn endAllocableString(self: *Parser) void { + const alloc = self.alloc.?; const list = self.buf_dynamic.?; - self.temp_state.str.* = list.items; + list.append(alloc, 0) catch { + log.warn("allocation failed on allocable string termination", .{}); + self.temp_state.str.* = ""; + return; + }; + + self.temp_state.str.* = list.items[0 .. list.items.len - 1 :0]; } /// End the sequence and return the command, if any. If the return value @@ -1976,6 +1996,36 @@ test "OSC: longer than buffer" { try testing.expect(p.complete == false); } +test "OSC: one shorter than buffer length" { + const testing = std.testing; + + var p: Parser = .init(); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings(title, cmd.change_window_title); +} + +test "OSC: exactly at buffer length" { + const testing = std.testing; + + var p: Parser = .init(); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + // This should be null because we always reserve space for a null terminator. + try testing.expect(p.end(null) == null); + try testing.expect(p.complete == false); +} + test "OSC: OSC 9;1 ConEmu sleep" { const testing = std.testing;