vt: add ghostty_type_json for struct layout metadata

Add a new C API function that returns a comptime-generated JSON string
describing the size, alignment, and field layout of every C API extern
struct. This lets FFI consumers (particularly WASM) construct structs
by byte offset without hardcoding platform-specific layout.

The JSON is built at comptime using std.json.Stringify via a
StructInfo type that holds per-struct metadata and implements
jsonStringify. A StaticStringMap keyed by C struct name provides
lookup by name as well as iteration for the JSON serialization.

The function is declared in types.h alongside the other common types
and exported as ghostty_type_json.
This commit is contained in:
Mitchell Hashimoto
2026-03-30 08:48:52 -07:00
parent 8fab3ac3f3
commit 2e827cc39d
4 changed files with 256 additions and 1 deletions

View File

@@ -83,4 +83,42 @@ typedef struct {
#define GHOSTTY_INIT_SIZED(type) \
((type){ .size = sizeof(type) })
/**
* Return a pointer to a JSON string describing the layout of every
* C API struct 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.
*
* 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" }
* }
* }
* }
* @endcode
*
* The returned pointer is valid for the lifetime of the process.
* The length of the string (excluding any null terminator) is
* written to @p len.
*
* @param[out] len Receives the length of the returned string in bytes.
* @return Pointer to the JSON string.
*/
const char *ghostty_type_json(size_t *len);
#endif /* GHOSTTY_VT_TYPES_H */

View File

@@ -219,6 +219,7 @@ comptime {
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
@export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" });
@export(&c.build_info, .{ .name = "ghostty_build_info" });
@export(&c.type_json, .{ .name = "ghostty_type_json" });
@export(&c.alloc_alloc, .{ .name = "ghostty_alloc" });
@export(&c.alloc_free, .{ .name = "ghostty_free" });

View File

@@ -7,6 +7,8 @@ pub const cell = @import("cell.zig");
pub const color = @import("color.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const grid_ref = @import("grid_ref.zig");
pub const types = @import("types.zig");
pub const modes = @import("modes.zig");
pub const osc = @import("osc.zig");
pub const render = @import("render.zig");
@@ -141,7 +143,8 @@ pub const terminal_mode_set = terminal.mode_set;
pub const terminal_get = terminal.get;
pub const terminal_grid_ref = terminal.grid_ref;
const grid_ref = @import("grid_ref.zig");
pub const type_json = types.get_json;
pub const grid_ref_cell = grid_ref.grid_ref_cell;
pub const grid_ref_row = grid_ref.grid_ref_row;
pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes;
@@ -168,6 +171,7 @@ test {
_ = size_report;
_ = style;
_ = terminal;
_ = types;
// We want to make sure we run the tests for the C allocator interface.
_ = @import("../../lib/allocator.zig");

212
src/terminal/c/types.zig Normal file
View File

@@ -0,0 +1,212 @@
//! Comptime-generated metadata describing the layout of all C API
//! extern structs for the current target.
//!
//! This is embedded in the binary as a const string and exposed via
//! `ghostty_struct_meta` so that WASM (and other FFI) consumers can
//! build structs without hardcoding byte offsets.
const std = @import("std");
const lib = @import("../lib.zig");
const terminal = @import("terminal.zig");
const formatter = @import("formatter.zig");
const render = @import("render.zig");
const style_c = @import("style.zig");
const mouse_encode = @import("mouse_encode.zig");
const grid_ref = @import("grid_ref.zig");
/// All C API structs and their Ghostty C names.
pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
.{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
});
/// The comptime-generated JSON string of all structs.
pub const json: []const u8 = json: {
@setEvalBranchQuota(50000);
var counter: std.Io.Writer.Discarding = .init(&.{});
jsonWriteAll(&counter.writer) catch unreachable;
var buf: [counter.count]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
jsonWriteAll(&writer) catch unreachable;
const final = buf;
break :json final[0..writer.end];
};
/// Returns a pointer to the comptime-generated JSON string describing
/// the layout of all C API extern structs, and writes its length to `len`.
/// Exported as `ghostty_type_json` for FFI consumers.
pub fn get_json(len: *usize) callconv(lib.calling_conv) [*]const u8 {
len.* = json.len;
return json.ptr;
}
/// Meta information about a struct that we expose to ease writing
/// bindings in some languages, particularly WASM where we can't
/// easily share struct definitions and need to hardcode byte offsets.
pub const StructInfo = struct {
name: []const u8,
size: usize,
@"align": usize,
fields: []const FieldInfo,
pub const FieldInfo = struct {
name: []const u8,
offset: usize,
size: usize,
type: []const u8,
};
pub fn init(comptime T: type) StructInfo {
comptime {
const fields = @typeInfo(T).@"struct".fields;
const field_infos: [fields.len]FieldInfo = blk: {
var infos: [fields.len]FieldInfo = undefined;
for (fields, 0..) |field, i| infos[i] = .{
.name = field.name,
.offset = @offsetOf(T, field.name),
.size = @sizeOf(field.type),
.type = typeName(field.type),
};
break :blk infos;
};
return .{
.name = @typeName(T),
.size = @sizeOf(T),
.@"align" = @alignOf(T),
.fields = &field_infos,
};
}
}
pub fn jsonStringify(
self: *const StructInfo,
jws: anytype,
) std.Io.Writer.Error!void {
try jws.beginObject();
try jws.objectField("size");
try jws.write(self.size);
try jws.objectField("align");
try jws.write(self.@"align");
try jws.objectField("fields");
try jws.beginObject();
for (self.fields) |field| {
try jws.objectField(field.name);
try jws.beginObject();
try jws.objectField("offset");
try jws.write(field.offset);
try jws.objectField("size");
try jws.write(field.size);
try jws.objectField("type");
try jws.write(field.type);
try jws.endObject();
}
try jws.endObject();
try jws.endObject();
}
};
fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void {
var jws: std.json.Stringify = .{ .writer = writer };
try jws.beginObject();
for (structs.keys(), structs.values()) |name, *info| {
try jws.objectField(name);
try info.jsonStringify(&jws);
}
try jws.endObject();
}
fn typeName(comptime T: type) []const u8 {
return switch (@typeInfo(T)) {
.bool => "bool",
.int => |info| switch (info.signedness) {
.signed => switch (info.bits) {
8 => "i8",
16 => "i16",
32 => "i32",
64 => "i64",
else => @compileError("unsupported signed int size"),
},
.unsigned => switch (info.bits) {
8 => "u8",
16 => "u16",
32 => "u32",
64 => "u64",
else => @compileError("unsupported unsigned int size"),
},
},
.@"enum" => "enum",
.@"struct" => "struct",
.pointer => "pointer",
.array => "array",
else => "opaque",
};
}
test "json parses" {
const parsed = try std.json.parseFromSlice(
std.json.Value,
std.testing.allocator,
json,
.{},
);
defer parsed.deinit();
const root = parsed.value.object;
// Verify we have all expected structs
try std.testing.expect(root.contains("GhosttyTerminalOptions"));
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
// Verify GhosttyTerminalOptions fields
const term_opts = root.get("GhosttyTerminalOptions").?.object;
try std.testing.expect(term_opts.contains("size"));
try std.testing.expect(term_opts.contains("align"));
try std.testing.expect(term_opts.contains("fields"));
const fields = term_opts.get("fields").?.object;
try std.testing.expect(fields.contains("cols"));
try std.testing.expect(fields.contains("rows"));
try std.testing.expect(fields.contains("max_scrollback"));
// Verify field offsets make sense (cols should be at 0)
const cols = fields.get("cols").?.object;
try std.testing.expectEqual(0, cols.get("offset").?.integer);
}
test "struct sizes are non-zero" {
const parsed = try std.json.parseFromSlice(
std.json.Value,
std.testing.allocator,
json,
.{},
);
defer parsed.deinit();
var it = parsed.value.object.iterator();
while (it.next()) |entry| {
const struct_info = entry.value_ptr.object;
const size = struct_info.get("size").?.integer;
try std.testing.expect(size > 0);
}
}
test "structs map has all entries" {
try std.testing.expect(structs.get("GhosttyTerminalOptions") != null);
try std.testing.expect(structs.get("GhosttyFormatterTerminalOptions") != null);
try std.testing.expect(structs.get("GhosttyFormatterTerminalExtra") != null);
try std.testing.expect(structs.get("GhosttyFormatterScreenExtra") != null);
try std.testing.expect(structs.get("GhosttyRenderStateColors") != null);
try std.testing.expect(structs.get("GhosttyStyle") != null);
try std.testing.expect(structs.get("GhosttyStyleColor") != null);
try std.testing.expect(structs.get("GhosttyMouseEncoderSize") != null);
try std.testing.expect(structs.get("GhosttyGridRef") != null);
}