libghostty: expand ABI type metadata

The type metadata export only described extern struct layouts, leaving embedders to mirror enum values and tagged union relationships.

Describe every public C type in a versioned manifest with target and build metadata. Keep union field renames alongside their source tagged unions so the manifest uses public C names without changing Zig value layouts.
This commit is contained in:
Mitchell Hashimoto
2026-08-15 20:06:51 -07:00
parent ad6e72ddc4
commit 9673a22b01
11 changed files with 945 additions and 327 deletions

View File

@@ -156,7 +156,7 @@
// Look up a field's offset and DataView setter from the type layout JSON.
function fieldInfo(structName, fieldName) {
const field = typeLayout[structName].fields[fieldName];
const field = typeLayout.types[structName].fields[fieldName];
return field;
}
@@ -166,9 +166,16 @@
switch (field.type) {
case 'u8': case 'bool': view.setUint8(field.offset, value); break;
case 'u16': view.setUint16(field.offset, value, true); break;
case 'u32': case 'enum': view.setUint32(field.offset, value, true); break;
case 'u32': view.setUint32(field.offset, value, true); break;
case 'u64': view.setBigUint64(field.offset, BigInt(value), true); break;
default: throw new Error(`Unsupported field type: ${field.type}`);
default: {
const type = typeLayout.types[field.type];
if (type?.kind === 'enum' && type.underlying === 'i32') {
view.setInt32(field.offset, value, true);
break;
}
throw new Error(`Unsupported field type: ${field.type}`);
}
}
}
@@ -186,15 +193,14 @@
.replace(/\\\\/g, '\\');
}
// GHOSTTY_FORMATTER_FORMAT_PLAIN = 0
const GHOSTTY_FORMATTER_FORMAT_PLAIN = 0;
// GHOSTTY_SUCCESS = 0
const GHOSTTY_SUCCESS = 0;
function run() {
const outputDiv = document.getElementById('output');
try {
const GHOSTTY_FORMATTER_FORMAT_PLAIN =
typeLayout.types.GhosttyFormatterFormat.values.PLAIN;
const GHOSTTY_SUCCESS =
typeLayout.types.GhosttyResult.values.SUCCESS;
const cols = parseInt(document.getElementById('cols').value, 10);
const rows = parseInt(document.getElementById('rows').value, 10);
const vtText = parseEscapes(document.getElementById('vtInput').value);
@@ -224,7 +230,7 @@
wasmInstance.exports.ghostty_wasm_free_u8_array(dataPtr, vtBytes.length);
// Create a plain-text formatter
const FMT_OPTS_SIZE = typeLayout['GhosttyFormatterTerminalOptions'].size;
const FMT_OPTS_SIZE = typeLayout.types.GhosttyFormatterTerminalOptions.size;
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(FMT_OPTS_SIZE);
new Uint8Array(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE).fill(0);
const fmtOptsView = new DataView(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE);
@@ -235,12 +241,12 @@
// Set the nested sized-struct `size` fields for extra and extra.screen
const extraOffset = fieldInfo('GhosttyFormatterTerminalOptions', 'extra').offset;
const extraSize = typeLayout['GhosttyFormatterTerminalExtra'].size;
const extraSize = typeLayout.types.GhosttyFormatterTerminalExtra.size;
const extraSizeField = fieldInfo('GhosttyFormatterTerminalExtra', 'size');
fmtOptsView.setUint32(extraOffset + extraSizeField.offset, extraSize, true);
const screenOffset = fieldInfo('GhosttyFormatterTerminalExtra', 'screen').offset;
const screenSize = typeLayout['GhosttyFormatterScreenExtra'].size;
const screenSize = typeLayout.types.GhosttyFormatterScreenExtra.size;
const screenSizeField = fieldInfo('GhosttyFormatterScreenExtra', 'size');
fmtOptsView.setUint32(extraOffset + screenOffset + screenSizeField.offset, screenSize, true);

View File

@@ -63,6 +63,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23,
GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24,
GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25,
GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyOscCommandType;

View File

@@ -332,29 +332,40 @@ typedef struct {
#endif
/**
* Return a pointer to a null-terminated JSON string describing the
* layout of every C API struct for the current target.
* Return the versioned libghostty-vt C type manifest for the current target.
*
* This is primarily useful for language bindings that can't easily
* set C struct fields and need to do so via byte offsets. For example,
* WebAssembly modules can't share struct definitions with the host.
* The manifest defines all the public types available in the linked
* build. The types contain their layouts, enum values, union fields, and more.
*
* Language bindings, such as WebAssembly hosts, should obtain offsets,
* sizes, alignments, array shapes, enum constants, and tagged-union arms from
* this manifest rather than hardcoding them. Consumers should reject unknown
* schema versions and verify the descriptors they require at initialization.
*
* Example (abbreviated):
* @code{.json}
* {
* "GhosttyMouseEncoderSize": {
* "size": 40,
* "align": 8,
* "fields": {
* "size": { "offset": 0, "size": 8, "type": "u64" },
* "screen_width": { "offset": 8, "size": 4, "type": "u32" },
* "screen_height": { "offset": 12, "size": 4, "type": "u32" },
* "cell_width": { "offset": 16, "size": 4, "type": "u32" },
* "cell_height": { "offset": 20, "size": 4, "type": "u32" },
* "padding_top": { "offset": 24, "size": 4, "type": "u32" },
* "padding_bottom": { "offset": 28, "size": 4, "type": "u32" },
* "padding_right": { "offset": 32, "size": 4, "type": "u32" },
* "padding_left": { "offset": 36, "size": 4, "type": "u32" }
* "schema": 1,
* "abi": {
* "target": "wasm32", "os": "freestanding", "environment": "none",
* "pointer_size": 4, "usize_size": 4, "endian": "little"
* },
* "types": {
* "GhosttyRenderStateData": {
* "kind": "enum", "size": 4, "align": 4,
* "underlying": "i32", "prefix": "GHOSTTY_RENDER_STATE_DATA_",
* "values": { "INVALID": 0, "DIRTY": 3, "MAX_VALUE": 2147483647 }
* },
* "GhosttyStyleColor": {
* "kind": "struct", "size": 16, "align": 8,
* "fields": {
* "tag": { "offset": 0, "size": 4,
* "type": "GhosttyStyleColorTag" },
* "value": { "offset": 8, "size": 8,
* "type": "GhosttyStyleColorValue", "tag": "tag",
* "arms": { "NONE": null, "PALETTE": "palette",
* "RGB": "rgb" } }
* }
* }
* }
* }

View File

@@ -15,14 +15,18 @@ const Target = @import("target.zig").Target;
/// be an enum created with the `Enum` function in this library, so that
/// automatic C ABI compatibility is ensured.
///
/// The `Padding` type is a type that is always added to the C union
/// with the key `_padding`. This should be set to a type that has the size
/// and alignment needed to pad the C union to the expected size. This
/// should never change to ensure ABI compatibility.
/// `options.padding` is a type that is always added to the C union with the key
/// `_padding`. It should have the size and alignment needed to pad the C union
/// to the expected size and should never change to ensure ABI compatibility.
///
/// Each tag has an optional field in `options.field_renames` that may rename its
/// C value field. Multiple tags may map to the same field when their C value
/// types are identical. Tags with no payload are omitted. This is used
/// for metadata and should match the C headers directly.
pub fn TaggedUnion(
comptime target: Target,
comptime Union: type,
comptime Padding: type,
comptime options: TaggedUnionOptions(Union),
) type {
return struct {
comptime {
@@ -30,7 +34,7 @@ pub fn TaggedUnion(
.zig => {},
// For ABI compatibility, we expect that this is our union size.
.c => if (@sizeOf(CValue) != @sizeOf(Padding)) {
.c => if (@sizeOf(CValue) != @sizeOf(options.padding)) {
@compileLog(@sizeOf(CValue));
@compileError("TaggedUnion CValue size does not match expected fixed size");
},
@@ -49,11 +53,25 @@ pub fn TaggedUnion(
.c => extern struct {
tag: Tag,
value: CValue,
/// Returns the public C value-union field name for `tag`, or
/// null when the tag has no value field. This is metadata only;
/// it does not rename fields in `CValue`.
pub fn cFieldRename(comptime tag: Tag) ?[]const u8 {
const tag_name = @tagName(tag);
const value = @field(@unionInit(Union, tag_name, undefined), tag_name);
if (@field(options.field_renames, tag_name)) |name| return name;
if (@sizeOf(@TypeOf(value)) == 0) return null;
return tag_name;
}
},
};
/// The C ABI compatible union value type.
pub const CValue = cvalue: {
@setEvalBranchQuota(10_000);
switch (target) {
.zig => break :cvalue extern struct {},
.c => {},
@@ -83,8 +101,8 @@ pub fn TaggedUnion(
}
names[tag_fields.len] = "_padding";
types[tag_fields.len] = Padding;
attrs[tag_fields.len] = .{ .@"align" = @alignOf(Padding) };
types[tag_fields.len] = options.padding;
attrs[tag_fields.len] = .{ .@"align" = @alignOf(options.padding) };
break :cvalue @Union(.@"extern", null, &names, &types, &attrs);
};
@@ -125,6 +143,30 @@ pub fn TaggedUnion(
};
}
/// Options for generating the C representation of a tagged union.
pub fn TaggedUnionOptions(comptime Union: type) type {
const Tag = @typeInfo(Union).@"union".tag_type.?;
const tag_fields = @typeInfo(Tag).@"enum".fields;
const FieldRenames: type = field_renames: {
const default_rename: ?[]const u8 = null;
var names: [tag_fields.len][]const u8 = undefined;
var types: [tag_fields.len]type = undefined;
var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined;
for (tag_fields, 0..) |field, i| {
names[i] = field.name;
types[i] = ?[]const u8;
attrs[i] = .{ .default_value_ptr = &default_rename };
}
break :field_renames @Struct(.auto, null, &names, &types, &attrs);
};
return struct {
padding: type,
field_renames: FieldRenames = .{},
};
}
test "TaggedUnion: matching size" {
const Tag = enum(c_int) { a, b };
const U = TaggedUnion(
@@ -133,7 +175,7 @@ test "TaggedUnion: matching size" {
a: u32,
b: u64,
},
u64,
.{ .padding = u64 },
);
try testing.expectEqual(8, @sizeOf(U.CValue));
@@ -146,7 +188,7 @@ test "TaggedUnion: padded size" {
union(Tag) {
a: u32,
},
u64,
.{ .padding = u64 },
);
try testing.expectEqual(8, @sizeOf(U.CValue));
@@ -157,9 +199,39 @@ test "TaggedUnion: c conversion" {
const U = TaggedUnion(.c, union(Tag) {
a: u32,
b: u64,
}, u64);
}, .{ .padding = u64 });
const c = U.cval(.{ .a = 42 });
try testing.expectEqual(Tag.a, c.tag);
try testing.expectEqual(42, c.value.a);
}
test "TaggedUnion: custom C value fields" {
const Tag = enum(c_int) { a, b, none };
const Union = union(Tag) {
a: u32,
b: u32,
none,
};
const U = TaggedUnion(.c, Union, .{
.padding = u64,
.field_renames = .{
.a = "value",
.b = "value",
},
});
try testing.expect(!@hasField(U.CValue, "value"));
try testing.expect(@hasField(U.CValue, "a"));
try testing.expect(@hasField(U.CValue, "b"));
try testing.expect(@hasField(U.CValue, "none"));
try testing.expectEqualStrings("value", U.C.cFieldRename(.a).?);
try testing.expect(U.C.cFieldRename(.none) == null);
const a = U.cval(.{ .a = 42 });
try testing.expectEqual(Tag.a, a.tag);
try testing.expectEqual(42, a.value.a);
const none = U.cval(.none);
try testing.expectEqual(Tag.none, none.tag);
}

View File

@@ -2590,7 +2590,7 @@ pub const ScrollViewport = union(Tag) {
@This(),
// Padding: largest variant is isize (8 bytes on 64-bit).
// Use [2]u64 (16 bytes) for future expansion.
[2]u64,
.{ .padding = [2]u64 },
);
pub const C = c_union.C;
pub const CValue = c_union.CValue;

View File

@@ -12,6 +12,9 @@ pub const Parser = ?*osc.Parser;
/// C: GhosttyOscCommand
pub const Command = ?*osc.Command;
/// C: GhosttyOscCommandType
pub const CommandType = osc.Command.Key;
pub fn new(
alloc_: ?*const CAllocator,
result: *Parser,
@@ -44,7 +47,7 @@ pub fn end(parser_: Parser, terminator: u8) callconv(lib.calling_conv) Command {
return parser_.?.end(terminator);
}
pub fn commandType(command_: Command) callconv(lib.calling_conv) osc.Command.Key {
pub fn commandType(command_: Command) callconv(lib.calling_conv) CommandType {
const command = command_ orelse return .invalid;
return command.*;
}

View File

@@ -182,7 +182,7 @@ pub const UnknownSequence = union(Tag) {
// A future borrowed CSI payload may need parameter, separator, and
// intermediate arrays. Reserve 128 bytes so that representation and
// other structured sequence types can be added without an ABI break.
[16]u64,
.{ .padding = [16]u64 },
);
pub const C = c_union.C;
pub const CValue = c_union.CValue;

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,15 @@ pub const Point = union(Tag) {
@This(),
// Padding: largest variant is Coordinate (u16 + u32 = 6 bytes).
// Use [2]u64 (16 bytes) for future expansion.
[2]u64,
.{
.padding = [2]u64,
.field_renames = .{
.active = "coordinate",
.viewport = "coordinate",
.screen = "coordinate",
.history = "coordinate",
},
},
);
pub const C = c_union.C;
pub const CValue = c_union.CValue;

View File

@@ -161,7 +161,18 @@ pub const Attribute = union(Tag) {
// Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit.
// We use [8]u64 (64 bytes) to allow room for future expansion while
// maintaining ABI compatibility.
[8]u64,
.{
.padding = [8]u64,
.field_renames = .{
.@"256_underline_color" = "underline_color_256",
.@"8_bg" = "bg_8",
.@"8_fg" = "fg_8",
.@"8_bright_bg" = "bright_bg_8",
.@"8_bright_fg" = "bright_fg_8",
.@"256_bg" = "bg_256",
.@"256_fg" = "fg_256",
},
},
);
pub const Value = c_union.Value;
pub const C = c_union.C;

View File

@@ -236,7 +236,7 @@ pub const Action = union(Key) {
@This(),
// TODO: Before shipping an ABI-compatible libghostty, verify this.
// This was just arbitrarily chosen for now.
[16]u64,
.{ .padding = [16]u64 },
);
pub const Tag = c_union.Tag;
pub const Value = c_union.Value;