lib-vt: C API for SGR parser (#9352)

This exposes the SGR parser to the C and Wasm APIs. An example is shown
in c-vt-sgr.

Compressed example:

```c
#include <assert.h>
#include <stdio.h>
#include <ghostty/vt.h>

int main() {
  // Create parser
  GhosttySgrParser parser;
  assert(ghostty_sgr_new(NULL, &parser) == GHOSTTY_SUCCESS);

  // Parse: ESC[1;31m (bold + red foreground)
  uint16_t params[] = {1, 31};
  assert(ghostty_sgr_set_params(parser, params, NULL, 2) == GHOSTTY_SUCCESS);

  printf("Parsing: ESC[1;31m\n\n");

  // Iterate through attributes
  GhosttySgrAttribute attr;
  while (ghostty_sgr_next(parser, &attr)) {
    switch (attr.tag) {
      case GHOSTTY_SGR_ATTR_BOLD:
        printf("✓ Bold enabled\n");
        break;
      case GHOSTTY_SGR_ATTR_FG_8:
        printf("✓ Foreground color: %d (red)\n", attr.value.fg_8);
        break;
      default:
        break;
    }
  }

  ghostty_sgr_free(parser);
  return 0;
}
```

**AI disclosure:** Amp wrote most of the C headers, but I verified it
all. https://ampcode.com/threads/T-d9f145cb-e6ef-48a8-ad63-e5fc85c0d43e
This commit is contained in:
Mitchell Hashimoto
2025-10-25 21:26:06 -07:00
committed by GitHub
parent 27b0978cd5
commit a82ad89ef3
15 changed files with 782 additions and 11 deletions

View File

@@ -98,13 +98,6 @@ comptime {
// we want to reference the C API so that it gets exported.
if (@import("root") == lib) {
const c = terminal.c_api;
@export(&c.osc_new, .{ .name = "ghostty_osc_new" });
@export(&c.osc_free, .{ .name = "ghostty_osc_free" });
@export(&c.osc_next, .{ .name = "ghostty_osc_next" });
@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" });
@export(&c.key_event_new, .{ .name = "ghostty_key_event_new" });
@export(&c.key_event_free, .{ .name = "ghostty_key_event_free" });
@export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" });
@@ -125,7 +118,19 @@ comptime {
@export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" });
@export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" });
@export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" });
@export(&c.osc_new, .{ .name = "ghostty_osc_new" });
@export(&c.osc_free, .{ .name = "ghostty_osc_free" });
@export(&c.osc_next, .{ .name = "ghostty_osc_next" });
@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" });
@export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" });
@export(&c.sgr_new, .{ .name = "ghostty_sgr_new" });
@export(&c.sgr_free, .{ .name = "ghostty_sgr_free" });
@export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" });
@export(&c.sgr_set_params, .{ .name = "ghostty_sgr_set_params" });
@export(&c.sgr_next, .{ .name = "ghostty_sgr_next" });
// On Wasm we need to export our allocator convenience functions.
if (builtin.target.cpu.arch.isWasm()) {

View File

@@ -2,6 +2,7 @@ pub const osc = @import("osc.zig");
pub const key_event = @import("key_event.zig");
pub const key_encode = @import("key_encode.zig");
pub const paste = @import("paste.zig");
pub const sgr = @import("sgr.zig");
// The full C API, unexported.
pub const osc_new = osc.new;
@@ -12,6 +13,12 @@ pub const osc_end = osc.end;
pub const osc_command_type = osc.commandType;
pub const osc_command_data = osc.commandData;
pub const sgr_new = sgr.new;
pub const sgr_free = sgr.free;
pub const sgr_reset = sgr.reset;
pub const sgr_set_params = sgr.setParams;
pub const sgr_next = sgr.next;
pub const key_event_new = key_event.new;
pub const key_event_free = key_event.free;
pub const key_event_set_action = key_event.set_action;
@@ -41,6 +48,7 @@ test {
_ = key_event;
_ = key_encode;
_ = paste;
_ = sgr;
// We want to make sure we run the tests for the C allocator interface.
_ = @import("../../lib/allocator.zig");

View File

@@ -2,4 +2,5 @@
pub const Result = enum(c_int) {
success = 0,
out_of_memory = -1,
invalid_value = -2,
};

142
src/terminal/c/sgr.zig Normal file
View File

@@ -0,0 +1,142 @@
const std = @import("std");
const assert = std.debug.assert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const lib_alloc = @import("../../lib/allocator.zig");
const CAllocator = lib_alloc.Allocator;
const sgr = @import("../sgr.zig");
const Result = @import("result.zig").Result;
const log = std.log.scoped(.sgr);
/// Wrapper around parser that tracks the allocator for C API usage.
const ParserWrapper = struct {
parser: sgr.Parser,
alloc: Allocator,
};
/// C: GhosttySgrParser
pub const Parser = ?*ParserWrapper;
pub fn new(
alloc_: ?*const CAllocator,
result: *Parser,
) callconv(.c) Result {
const alloc = lib_alloc.default(alloc_);
const ptr = alloc.create(ParserWrapper) catch
return .out_of_memory;
ptr.* = .{
.parser = .empty,
.alloc = alloc,
};
result.* = ptr;
return .success;
}
pub fn free(parser_: Parser) callconv(.c) void {
const wrapper = parser_ orelse return;
const alloc = wrapper.alloc;
const parser: *sgr.Parser = &wrapper.parser;
if (parser.params.len > 0) alloc.free(parser.params);
alloc.destroy(wrapper);
}
pub fn reset(parser_: Parser) callconv(.c) void {
const wrapper = parser_ orelse return;
const parser: *sgr.Parser = &wrapper.parser;
parser.idx = 0;
}
pub fn setParams(
parser_: Parser,
params: [*]const u16,
seps_: ?[*]const u8,
len: usize,
) callconv(.c) Result {
const wrapper = parser_ orelse return .invalid_value;
const alloc = wrapper.alloc;
const parser: *sgr.Parser = &wrapper.parser;
// Copy our new parameters
const params_slice = alloc.dupe(u16, params[0..len]) catch
return .out_of_memory;
if (parser.params.len > 0) alloc.free(parser.params);
parser.params = params_slice;
// If we have separators, set that state too.
parser.params_sep = .initEmpty();
if (seps_) |seps| {
if (len > @TypeOf(parser.params_sep).bit_length) {
log.warn("ghostty_sgr_set_params: separators length {} exceeds max supported length {}", .{
len,
@TypeOf(parser.params_sep).bit_length,
});
return .invalid_value;
}
for (seps[0..len], 0..) |sep, i| {
if (sep == ':') parser.params_sep.set(i);
}
}
// Reset our parsing state
parser.idx = 0;
return .success;
}
pub fn next(
parser_: Parser,
result: *sgr.Attribute.C,
) callconv(.c) bool {
const wrapper = parser_ orelse return false;
const parser: *sgr.Parser = &wrapper.parser;
if (parser.next()) |attr| {
result.* = attr.cval();
return true;
}
return false;
}
test "alloc" {
var p: Parser = undefined;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&p,
));
free(p);
}
test "simple params, no seps" {
var p: Parser = undefined;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&p,
));
defer free(p);
try testing.expectEqual(Result.success, setParams(
p,
&.{1},
null,
1,
));
// Set it twice on purpose to make sure we don't leak.
try testing.expectEqual(Result.success, setParams(
p,
&.{1},
null,
1,
));
// Verify we get bold
var attr: sgr.Attribute.C = undefined;
try testing.expect(next(p, &attr));
try testing.expectEqual(.bold, attr.tag);
// Nothing else
try testing.expect(!next(p, &attr));
}

View File

@@ -5,7 +5,6 @@ const stream = @import("stream.zig");
const ansi = @import("ansi.zig");
const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig");
const sgr = @import("sgr.zig");
const stream_readonly = @import("stream_readonly.zig");
const style = @import("style.zig");
pub const apc = @import("apc.zig");
@@ -19,6 +18,7 @@ pub const modes = @import("modes.zig");
pub const page = @import("page.zig");
pub const parse_table = @import("parse_table.zig");
pub const search = @import("search.zig");
pub const sgr = @import("sgr.zig");
pub const size = @import("size.zig");
pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {};
pub const x11_color = @import("x11_color.zig");

View File

@@ -151,7 +151,7 @@ pub const Attribute = union(Tag) {
dotted = 4,
dashed = 5,
pub const C = u8;
pub const C = c_int;
pub fn cval(self: Underline) Underline.C {
return @intFromEnum(self);
@@ -176,10 +176,13 @@ pub const Attribute = union(Tag) {
/// Parser parses the attributes from a list of SGR parameters.
pub const Parser = struct {
params: []const u16,
params: []const u16 = &.{},
params_sep: SepList = .initEmpty(),
idx: usize = 0,
/// Empty state parser.
pub const empty: Parser = .{};
/// Next returns the next attribute or null if there are no more attributes.
pub fn next(self: *Parser) ?Attribute {
if (self.idx >= self.params.len) {