diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 0263f905f..e06c5184c 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -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 */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ba76fef53..adfb11478 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 661bff147..170567796 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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"); diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig new file mode 100644 index 000000000..006f06165 --- /dev/null +++ b/src/terminal/c/types.zig @@ -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); +}