libghostty: ghostty_type_json expanded with more metadata, every enum member, packed layouts, etc. (#13856)

This PR makes `ghostty_type_json` contain more metadata necessary for
FFI without access to the C header to produce safe, ABI compliant field
access.

The existing `ghostty_type_json` didn't expose enough information:
embedders still had to hardcode enum values, tagged union values, and
packed bit layouts. For wasm this meant copying offsets and masks from
Zig internals and hoping they didn't drift. This isn't an ABI I want to
promise.

This also includes a formal JSON schema included in Doxygen docs and
used in CI to continuously validate our output structure. I'd like to
expand in the future comparing to actual C headers too.

## Examples

### Named References, Arrays

```json
"GhosttyRenderStateColors": {
  "kind": "struct",
  "size": 792,
  "align": 8,
  "fields": {
    "background": {
      "offset": 8,
      "size": 3,
      "type": "GhosttyColorRgb"
    },
    "palette": {
      "offset": 18,
      "size": 768,
      "type": "array",
      "elem": "GhosttyColorRgb",
      "count": 256
    }
  }
}
```

### Pointers

```json
"GhosttyCellsView": {
  "kind": "struct",
  "size": 16,
  "align": 8,
  "fields": {
    "ptr": {
      "offset": 0,
      "size": 8,
      "type": "pointer",
      "elem": "GhosttyCell",
      "const": true,
      "nullable": true
    },
    "len": {
      "offset": 8,
      "size": 8,
      "type": "u64"
    }
  }
}
```

### Enums

```json
"GhosttyStyleColorTag": {
  "kind": "enum",
  "size": 4,
  "align": 4,
  "underlying": "i32",
  "prefix": "GHOSTTY_STYLE_COLOR_",
  "values": {
    "NONE": 0,
    "PALETTE": 1,
    "RGB": 2,
    "TAG_MAX_VALUE": 2147483647
  }
}
```

### Packed Struct

```json
"GhosttyCell": {
  "kind": "packed",
  "size": 8,
  "align": 8,
  "underlying": "u64",
  "bits": {
    "content_tag": {
      "lsb": 0,
      "width": 2,
      "type": "GhosttyCellContentTag"
    },
    "content": {
      "lsb": 2,
      "width": 24,
      "kind": "union",
      "tag": "content_tag",
      "arms": {
        "BG_COLOR_RGB": {
          "kind": "packed",
          "width": 24,
          "bits": {
            "r": {"lsb": 0, "width": 8, "type": "u8"},
            "g": {"lsb": 8, "width": 8, "type": "u8"},
            "b": {"lsb": 16, "width": 8, "type": "u8"}
          }
        }
      }
    }
  }
}
```
This commit is contained in:
Mitchell Hashimoto
2026-08-16 06:43:14 -07:00
committed by GitHub
23 changed files with 2241 additions and 337 deletions

View File

@@ -701,6 +701,12 @@ jobs:
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseSmall
- name: Validate WASM ABI manifest
run: |
nix develop -c zig build test-lib-vt-schema \
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseSmall
- name: Optimize ReleaseSmall WASM
run: |
nix develop -c wasm-opt -O3 \
@@ -1470,6 +1476,9 @@ jobs:
- name: Test
run: nix develop -c zig build test-lib-vt
- name: Validate native ABI manifest
run: nix develop -c zig build test-lib-vt-schema
test-kaitai:
if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true'
needs: skip

View File

@@ -57,7 +57,8 @@ GENERATE_HTML = YES
HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty
HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css
HTML_EXTRA_FILES = dist/doxygen/favicon.png \
dist/doxygen/mobile-nav.js
dist/doxygen/mobile-nav.js \
src/terminal/c/types.schema.json
HTML_COLORSTYLE = DARK
HTML_CODE_FOLDING = NO
HTML_HEADER = dist/doxygen/header.html

View File

@@ -73,6 +73,10 @@ pub fn build(b: *std.Build) !void {
"test-lib-vt-build",
"Build libghostty-vt tests without running them (compile check)",
);
const test_lib_vt_schema_step = b.step(
"test-lib-vt-schema",
"Validate the libghostty-vt ABI type manifest",
);
const test_valgrind_step = b.step(
"test-valgrind",
"Run tests under valgrind",
@@ -136,6 +140,12 @@ pub fn build(b: *std.Build) !void {
};
libghostty_vt_shared.install(b.getInstallStep());
const type_schema_test = b.addSystemCommand(&.{"python3"});
type_schema_test.addFileArg(b.path("src/terminal/c/types-schema-verify.py"));
type_schema_test.addFileArg(b.path("src/terminal/c/types.schema.json"));
type_schema_test.addFileArg(libghostty_vt_shared.output);
test_lib_vt_schema_step.dependOn(&type_schema_test.step);
// libghostty-vt static lib
const libghostty_vt_static = try buildpkg.GhosttyLibVt.initStatic(
b,

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

@@ -256,9 +256,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
*
* This is the bulk alternative to iterating cells one at a time.
* It lets callers with expensive call boundaries (e.g. WebAssembly
* embedders) read an entire row with a single call, then drill
* into the cells iterator only for cells that need managed data
* (styles, graphemes). */
* embedders) read an entire row with a single call.
*
* Bit positions aren't protected by ABI, so callers should parse them
* out of the manifest from `ghostty_type_json`. Callers with access
* to the C header or without high FFI costs should use `ghostty_cell_get`.
*/
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW = 5,
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowData;

View File

@@ -21,19 +21,21 @@ extern "C" {
* Terminal screen cell and row types.
*
* These types represent the contents of a terminal screen. A GhosttyCell
* is a single grid cell and a GhosttyRow is a single row. Both are opaque
* values whose fields are accessed via ghostty_cell_get() and
* ghostty_row_get() respectively.
* is a single grid cell and a GhosttyRow is a single row. Cell fields can
* be accessed via ghostty_cell_get() or decoded from the packed layout in
* ghostty_type_json(). Rows are opaque and accessed via ghostty_row_get().
*
* @{
*/
/**
* Opaque cell value.
* Packed cell value.
*
* Represents a single terminal cell. The internal layout is opaque and
* must be queried via ghostty_cell_get(). Obtain cell values from
* terminal query APIs.
* Represents a single terminal cell. Portable callers can query fields via
* ghostty_cell_get(). Boundary-sensitive callers can decode the packed value
* using the GhosttyCell descriptor returned by ghostty_type_json(). The
* manifest is authoritative for the linked build; hardcoding bit positions
* is unsupported.
*
* @ingroup screen
*/
@@ -55,7 +57,8 @@ typedef uint64_t GhosttyRow;
*
* The memory is not owned by this struct. The pointer is only valid
* for the lifetime documented by the API that produces it. Each value
* is queried via ghostty_cell_get() like any other GhosttyCell.
* can be queried via ghostty_cell_get() or decoded using the GhosttyCell
* packed descriptor returned by ghostty_type_json().
*
* @ingroup screen
*/

View File

@@ -332,29 +332,50 @@ 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.
*
* Packed type descriptors define fields using `lsb` and `width`. `lsb` is
* relative to bit zero of the containing numerical value; for nested packed
* layouts it is relative to the immediate containing field. Tagged packed
* unions select an inline arm layout using the named tag field. These layouts
* describe the current linked build and are not a cross-version stability
* promise.
*
* The formal format is defined by the
* <a href="types.schema.json">libghostty-vt ABI manifest JSON Schema</a>.
*
* 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

@@ -91,8 +91,10 @@
inherit pkgs lib stdenv;
};
python = python3.withPackages (python-pkgs: [
python-pkgs.jsonschema
python-pkgs.kaitaistruct
python-pkgs.ucs-detect
python-pkgs.wasmtime
]);
in
mkShell {

View File

@@ -14,6 +14,7 @@ WORKDIR /ghostty
COPY include/ ./include/
COPY images/ ./images/
COPY dist/doxygen/ ./dist/doxygen/
COPY src/terminal/c/types.schema.json ./src/terminal/c/types.schema.json
COPY example/ ./example/
COPY Doxyfile ./
COPY DoxygenLayout.xml ./

View File

@@ -1,5 +1,6 @@
const std = @import("std");
const enumpkg = @import("enum.zig");
const packedpkg = @import("packed.zig");
const structpkg = @import("struct.zig");
const types = @import("types.zig");
const unionpkg = @import("union.zig");
@@ -8,6 +9,8 @@ pub const allocator = @import("allocator.zig");
pub const TinyIo = @import("TinyIo.zig");
pub const Buffer = types.Buffer;
pub const Enum = enumpkg.Enum;
pub const Packed = packedpkg.Packed;
pub const PackedTaggedUnion = packedpkg.PackedTaggedUnion;
pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum;
pub const String = types.String;
pub const Struct = structpkg.Struct;

290
src/lib/packed.zig Normal file
View File

@@ -0,0 +1,290 @@
const std = @import("std");
const testing = std.testing;
/// Metadata for a field in a packed layout.
pub const FieldOptions = struct {
/// Public name for this field. The Zig field name is used by default.
name: ?[]const u8 = null,
/// Public type name for scalar fields. The Zig type is used by default.
type_name: ?[]const u8 = null,
/// Omit this field from public metadata. This is intended for padding.
omit: bool = false,
/// How the field is represented in public metadata.
encoding: Encoding = .scalar,
pub const Encoding = union(enum) {
scalar,
@"packed": type,
tagged_union: type,
};
};
/// Create metadata for a packed struct. Physical layout information is always
/// reflected from `T`; options only provide public names and relationships.
pub fn Packed(
comptime T: type,
comptime options: PackedOptions(T),
) type {
const info = packedStructInfo(T);
const FieldT = std.meta.FieldEnum(T);
return struct {
pub const Zig = T;
// I know this is an insane amount of validation but getting
// this correct is critical to C APIs working so we go overboard.
comptime {
for (info.fields, 0..) |field, i| {
const field_options = @field(options.fields, field.name);
if (field_options.omit) {
if (field_options.name != null or
field_options.type_name != null or
field_options.encoding != .scalar)
{
@compileError("omitted packed field has other options: " ++ field.name);
}
continue;
}
if (field_options.name) |name| {
if (name.len == 0)
@compileError("packed field name cannot be empty: " ++ field.name);
}
switch (field_options.encoding) {
.scalar => switch (@typeInfo(field.type)) {
.bool, .int, .@"enum" => {},
else => @compileError("packed field requires an explicit encoding: " ++ field.name),
},
.@"packed" => |Layout| {
if (Layout.Zig != field.type)
@compileError("nested packed layout has the wrong Zig type: " ++ field.name);
if (field_options.type_name != null)
@compileError("nested packed field cannot have a scalar type name: " ++ field.name);
},
.tagged_union => |Layout| {
if (Layout.Owner != T or Layout.Union != field.type or
Layout.union_field != @field(FieldT, field.name))
{
@compileError("tagged union layout does not match packed field: " ++ field.name);
}
if (field_options.type_name != null)
@compileError("tagged union field cannot have a scalar type name: " ++ field.name);
},
}
const public_name = field_options.name orelse field.name;
for (info.fields[0..i]) |previous| {
const previous_options = @field(options.fields, previous.name);
if (previous_options.omit) continue;
const previous_name = previous_options.name orelse previous.name;
if (std.mem.eql(u8, public_name, previous_name))
@compileError("duplicate public packed field name: " ++ public_name);
}
}
}
pub const Backing = info.backing_integer.?;
pub const Field = std.meta.FieldEnum(T);
pub fn fieldOptions(comptime field: Field) FieldOptions {
return @field(options.fields, @tagName(field));
}
pub fn fieldName(comptime field: Field) ?[]const u8 {
const field_options = fieldOptions(field);
if (field_options.omit) return null;
return field_options.name orelse @tagName(field);
}
pub fn bitOffset(comptime field: Field) usize {
return @bitOffsetOf(T, @tagName(field));
}
pub fn bitWidth(comptime field: Field) usize {
return @bitSizeOf(@FieldType(T, @tagName(field)));
}
};
}
/// Options for a packed struct. A field is generated for every field in `T`
/// so unknown field names are rejected by normal Zig type checking.
pub fn PackedOptions(comptime T: type) type {
const fields = packedStructInfo(T).fields;
const default_options: FieldOptions = .{};
var names: [fields.len][]const u8 = undefined;
var types: [fields.len]type = undefined;
var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined;
for (fields, 0..) |field, i| {
names[i] = field.name;
types[i] = FieldOptions;
attrs[i] = .{ .default_value_ptr = &default_options };
}
const Fields = @Struct(.auto, null, &names, &types, &attrs);
return struct {
fields: Fields = .{},
};
}
/// Describe a packed union field whose active arm is selected by another
/// field in the containing packed struct. Each tag maps to a reflected packed
/// arm layout. Multiple tags may map to the same source union field.
pub fn PackedTaggedUnion(
comptime OwnerT: type,
comptime union_field_value: std.meta.FieldEnum(OwnerT),
comptime tag_field_value: std.meta.FieldEnum(OwnerT),
comptime options: PackedTaggedUnionOptions(
@FieldType(OwnerT, @tagName(union_field_value)),
@FieldType(OwnerT, @tagName(tag_field_value)),
),
) type {
_ = packedStructInfo(OwnerT);
const UnionT = @FieldType(OwnerT, @tagName(union_field_value));
const union_info = @typeInfo(UnionT).@"union";
if (union_info.layout != .@"packed")
@compileError("packed tagged union value must be a packed union");
const TagT = @FieldType(OwnerT, @tagName(tag_field_value));
const tag_info = @typeInfo(TagT).@"enum";
const ArmT = PackedTaggedUnionArm(UnionT);
return struct {
pub const Owner = OwnerT;
pub const Union = UnionT;
pub const Tag = TagT;
pub const Arm = ArmT;
pub const union_field = union_field_value;
pub const tag_field = tag_field_value;
comptime {
for (tag_info.fields) |tag| {
const arm_value = @field(options.arms, tag.name) orelse continue;
switch (arm_value) {
inline else => |Layout, source| {
const source_name = @tagName(source);
if (Layout.Zig != @FieldType(UnionT, source_name))
@compileError("packed union arm layout has the wrong Zig type for tag: " ++ tag.name);
if (@bitSizeOf(Layout.Zig) > @bitSizeOf(UnionT))
@compileError("packed union arm is wider than its union: " ++ tag.name);
},
}
}
}
pub fn arm(comptime tag: Tag) ?Arm {
return @field(options.arms, @tagName(tag));
}
};
}
/// Options for mapping tag values to packed union arms.
pub fn PackedTaggedUnionOptions(comptime Union: type, comptime Tag: type) type {
const union_info = @typeInfo(Union).@"union";
if (union_info.layout != .@"packed")
@compileError("packed tagged union value must be a packed union");
const tag_fields = @typeInfo(Tag).@"enum".fields;
const Arm = PackedTaggedUnionArm(Union);
const default_arm: ?Arm = 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] = ?Arm;
attrs[i] = .{ .default_value_ptr = &default_arm };
}
const Arms = @Struct(.auto, null, &names, &types, &attrs);
return struct {
arms: Arms = .{},
};
}
fn PackedTaggedUnionArm(comptime Union: type) type {
const fields = @typeInfo(Union).@"union".fields;
const Tag = std.meta.FieldEnum(Union);
var names: [fields.len][]const u8 = undefined;
var types: [fields.len]type = undefined;
var attrs: [fields.len]std.builtin.Type.UnionField.Attributes = undefined;
for (fields, 0..) |field, i| {
names[i] = field.name;
types[i] = type;
attrs[i] = .{ .@"align" = 1 };
}
return @Union(.auto, Tag, &names, &types, &attrs);
}
fn packedStructInfo(comptime T: type) std.builtin.Type.Struct {
const info = @typeInfo(T).@"struct";
if (info.layout != .@"packed")
@compileError("Packed requires a packed struct");
if (info.backing_integer == null)
@compileError("Packed requires an integer-backed packed struct");
return info;
}
test "Packed: reflected fields and nested tagged union" {
const Tag = enum(u2) { first, second, third };
const Value = packed union {
number: packed struct(u4) {
data: u3,
_pad: u1,
},
flags: packed struct(u4) {
a: bool,
b: bool,
_pad: u2,
},
};
const Nested = packed struct(u1) { enabled: bool };
const Subject = packed struct(u8) {
tag: Tag,
value: Value,
nested: Nested,
_padding: u1,
};
const Number = Packed(@FieldType(Value, "number"), .{ .fields = .{
.data = .{ .name = "number" },
._pad = .{ .omit = true },
} });
const Flags = Packed(@FieldType(Value, "flags"), .{ .fields = .{
._pad = .{ .omit = true },
} });
const NestedLayout = Packed(Nested, .{});
const ValueLayout = PackedTaggedUnion(Subject, .value, .tag, .{ .arms = .{
.first = .{ .number = Number },
.second = .{ .number = Number },
.third = .{ .flags = Flags },
} });
const Layout = Packed(Subject, .{ .fields = .{
.value = .{ .encoding = .{ .tagged_union = ValueLayout } },
.nested = .{ .encoding = .{ .@"packed" = NestedLayout } },
._padding = .{ .omit = true },
} });
try testing.expectEqual(@bitOffsetOf(Subject, "value"), Layout.bitOffset(.value));
try testing.expectEqual(@bitSizeOf(Value), Layout.bitWidth(.value));
try testing.expectEqualStrings("number", Number.fieldName(.data).?);
try testing.expect(Number.fieldName(._pad) == null);
switch (ValueLayout.arm(.first).?) {
.number => |ArmLayout| try testing.expect(ArmLayout == Number),
else => return error.TestUnexpectedResult,
}
switch (ValueLayout.arm(.second).?) {
.number => |ArmLayout| try testing.expect(ArmLayout == Number),
else => return error.TestUnexpectedResult,
}
try testing.expectEqual(@as(usize, 1), NestedLayout.bitWidth(.enabled));
}

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;

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Validate the ABI manifest exported by a native or WebAssembly library."""
from __future__ import annotations
import argparse
import ctypes
import json
import sys
from pathlib import Path
from typing import Any
try:
import jsonschema
except ImportError as error:
raise SystemExit(
"missing ABI schema verifier dependencies; run this inside "
"`nix develop`"
) from error
def load_native_manifest(path: Path) -> bytes:
"""Call ghostty_type_json in a native shared library."""
library = ctypes.CDLL(str(path.resolve()))
type_json = library.ghostty_type_json
type_json.argtypes = ()
type_json.restype = ctypes.c_char_p
result = type_json()
if result is None:
raise RuntimeError("ghostty_type_json returned NULL")
return result
def load_wasm_manifest(path: Path) -> bytes:
"""Call ghostty_type_json and read its result from WebAssembly memory."""
try:
import wasmtime
except ImportError as error:
raise SystemExit(
"missing WebAssembly verifier dependencies; run this inside "
"`nix develop`"
) from error
engine = wasmtime.Engine()
module = wasmtime.Module.from_file(engine, str(path))
store = wasmtime.Store(engine)
instance = wasmtime.Instance(store, module, [])
exports = instance.exports(store)
memory = exports["memory"]
type_json = exports["ghostty_type_json"]
pointer = type_json(store)
data = memory.read(store, pointer, memory.data_len(store))
terminator = data.find(b"\0")
if terminator < 0:
raise RuntimeError("ghostty_type_json result is not NUL terminated")
return bytes(data[:terminator])
def load_manifest(path: Path) -> dict[str, Any]:
"""Execute the public export and decode its JSON result."""
encoded = (
load_wasm_manifest(path)
if path.suffix.lower() == ".wasm"
else load_native_manifest(path)
)
value = json.loads(encoded)
if not isinstance(value, dict):
raise ValueError("ghostty_type_json did not return a JSON object")
return value
def format_path(parts: list[object]) -> str:
"""Format a jsonschema error path for command-line output."""
return "/" + "/".join(str(part) for part in parts)
def validate(schema_path: Path, library_path: Path) -> None:
"""Validate the schema itself and the manifest returned by the library."""
schema = json.loads(schema_path.read_bytes())
validator_type = jsonschema.validators.validator_for(schema)
validator_type.check_schema(schema)
manifest = load_manifest(library_path)
errors = sorted(
validator_type(schema).iter_errors(manifest),
key=lambda error: list(error.absolute_path),
)
if errors:
for error in errors:
print(
f"{format_path(list(error.absolute_path))}: {error.message}",
file=sys.stderr,
)
raise SystemExit(f"ABI manifest failed {schema_path}")
abi = manifest["abi"]
print(
f"validated {abi['target']}-{abi['os']} ABI manifest "
f"({len(manifest['types'])} types)"
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("schema", type=Path)
parser.add_argument("library", type=Path)
args = parser.parse_args()
validate(args.schema, args.library)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,501 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "libghostty-vt ABI type manifest",
"description": "Schema for the JSON returned by ghostty_type_json.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "abi", "library_version", "commit", "dirty", "types"],
"properties": {
"schema": {
"const": 1
},
"abi": {
"$ref": "#/$defs/abi"
},
"library_version": {
"type": "string",
"minLength": 1
},
"commit": {
"type": ["string", "null"],
"minLength": 1
},
"dirty": {
"type": ["boolean", "null"]
},
"types": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"pattern": "^Ghostty[A-Za-z0-9_]+$"
},
"additionalProperties": {
"$ref": "#/$defs/typeDescriptor"
}
}
},
"$defs": {
"nonNegativeInteger": {
"type": "integer",
"minimum": 0
},
"positiveInteger": {
"type": "integer",
"minimum": 1
},
"typeReference": {
"type": "string",
"pattern": "^(Ghostty[A-Za-z0-9_]+|bool|f32|f64|function|[iu][1-9][0-9]*|opaque|void)$"
},
"integerTypeReference": {
"type": "string",
"pattern": "^[iu][1-9][0-9]*$"
},
"abi": {
"type": "object",
"additionalProperties": false,
"required": [
"target",
"os",
"environment",
"pointer_size",
"usize_size",
"endian"
],
"properties": {
"target": {
"type": "string",
"minLength": 1
},
"os": {
"type": "string",
"minLength": 1
},
"environment": {
"type": "string",
"minLength": 1
},
"pointer_size": {
"$ref": "#/$defs/positiveInteger"
},
"usize_size": {
"$ref": "#/$defs/positiveInteger"
},
"endian": {
"enum": ["little", "big"]
}
}
},
"fieldBase": {
"type": "object",
"required": ["offset", "size", "type"],
"properties": {
"offset": {
"$ref": "#/$defs/nonNegativeInteger"
},
"size": {
"$ref": "#/$defs/nonNegativeInteger"
}
}
},
"plainField": {
"allOf": [
{
"$ref": "#/$defs/fieldBase"
},
{
"properties": {
"type": {
"$ref": "#/$defs/typeReference"
}
}
}
],
"unevaluatedProperties": false
},
"arrayField": {
"allOf": [
{
"$ref": "#/$defs/fieldBase"
},
{
"required": ["elem", "count"],
"properties": {
"type": {
"const": "array"
},
"elem": {
"$ref": "#/$defs/typeReference"
},
"count": {
"$ref": "#/$defs/nonNegativeInteger"
}
}
}
],
"unevaluatedProperties": false
},
"pointerField": {
"allOf": [
{
"$ref": "#/$defs/fieldBase"
},
{
"required": ["elem", "const"],
"properties": {
"type": {
"const": "pointer"
},
"elem": {
"$ref": "#/$defs/typeReference"
},
"const": {
"type": "boolean"
},
"nullable": {
"const": true
}
}
}
],
"unevaluatedProperties": false
},
"taggedUnionField": {
"allOf": [
{
"$ref": "#/$defs/fieldBase"
},
{
"required": ["tag", "arms"],
"properties": {
"type": {
"$ref": "#/$defs/typeReference"
},
"tag": {
"type": "string",
"minLength": 1
},
"arms": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"pattern": "^[A-Z][A-Z0-9_]*$"
},
"additionalProperties": {
"type": ["string", "null"]
}
}
}
}
],
"unevaluatedProperties": false
},
"field": {
"oneOf": [
{
"$ref": "#/$defs/plainField"
},
{
"$ref": "#/$defs/arrayField"
},
{
"$ref": "#/$defs/pointerField"
},
{
"$ref": "#/$defs/taggedUnionField"
}
]
},
"fields": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/field"
}
},
"bitBase": {
"type": "object",
"required": ["lsb", "width"],
"properties": {
"lsb": {
"description": "Least-significant bit relative to the containing numerical value.",
"$ref": "#/$defs/nonNegativeInteger"
},
"width": {
"description": "Physical width of this field in bits.",
"$ref": "#/$defs/positiveInteger"
}
}
},
"scalarBit": {
"allOf": [
{
"$ref": "#/$defs/bitBase"
},
{
"required": ["type"],
"properties": {
"type": {
"$ref": "#/$defs/typeReference"
}
}
}
],
"unevaluatedProperties": false
},
"packedBit": {
"allOf": [
{
"$ref": "#/$defs/bitBase"
},
{
"required": ["kind", "bits"],
"properties": {
"kind": {
"const": "packed"
},
"bits": {
"$ref": "#/$defs/bits"
}
}
}
],
"unevaluatedProperties": false
},
"packedArm": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "width", "bits"],
"properties": {
"kind": {
"const": "packed"
},
"width": {
"description": "Physical width of this inline arm in bits.",
"$ref": "#/$defs/positiveInteger"
},
"bits": {
"$ref": "#/$defs/bits"
}
}
},
"taggedUnionBit": {
"allOf": [
{
"$ref": "#/$defs/bitBase"
},
{
"required": ["kind", "tag", "arms"],
"properties": {
"kind": {
"const": "union"
},
"tag": {
"type": "string",
"minLength": 1
},
"arms": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"pattern": "^[A-Z][A-Z0-9_]*$"
},
"additionalProperties": {
"oneOf": [
{
"$ref": "#/$defs/packedArm"
},
{
"type": "null"
}
]
}
}
}
}
],
"unevaluatedProperties": false
},
"bit": {
"oneOf": [
{
"$ref": "#/$defs/scalarBit"
},
{
"$ref": "#/$defs/packedBit"
},
{
"$ref": "#/$defs/taggedUnionBit"
}
]
},
"bits": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"$ref": "#/$defs/bit"
}
},
"descriptorBase": {
"type": "object",
"required": ["kind", "size", "align"],
"properties": {
"size": {
"$ref": "#/$defs/nonNegativeInteger"
},
"align": {
"$ref": "#/$defs/positiveInteger"
}
}
},
"structDescriptor": {
"allOf": [
{
"$ref": "#/$defs/descriptorBase"
},
{
"required": ["fields"],
"properties": {
"kind": {
"const": "struct"
},
"fields": {
"$ref": "#/$defs/fields"
}
}
}
],
"unevaluatedProperties": false
},
"unionDescriptor": {
"allOf": [
{
"$ref": "#/$defs/descriptorBase"
},
{
"required": ["fields"],
"properties": {
"kind": {
"const": "union"
},
"fields": {
"$ref": "#/$defs/fields"
}
}
}
],
"unevaluatedProperties": false
},
"enumDescriptor": {
"allOf": [
{
"$ref": "#/$defs/descriptorBase"
},
{
"required": ["underlying", "prefix", "values"],
"properties": {
"kind": {
"const": "enum"
},
"underlying": {
"const": "i32"
},
"prefix": {
"type": "string",
"minLength": 1
},
"values": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"pattern": "^[A-Z][A-Z0-9_]*$"
},
"additionalProperties": {
"type": "integer"
}
}
}
}
],
"unevaluatedProperties": false
},
"packedDescriptor": {
"allOf": [
{
"$ref": "#/$defs/descriptorBase"
},
{
"required": ["underlying", "bits"],
"properties": {
"kind": {
"const": "packed"
},
"underlying": {
"description": "Integer backing type used to interpret the packed value.",
"$ref": "#/$defs/integerTypeReference"
},
"bits": {
"$ref": "#/$defs/bits"
}
}
}
],
"unevaluatedProperties": false
},
"aliasDescriptor": {
"allOf": [
{
"$ref": "#/$defs/descriptorBase"
},
{
"required": ["type"],
"properties": {
"kind": {
"const": "alias"
},
"type": {
"$ref": "#/$defs/typeReference"
}
}
}
],
"unevaluatedProperties": false
},
"opaqueDescriptor": {
"allOf": [
{
"$ref": "#/$defs/descriptorBase"
},
{
"properties": {
"kind": {
"const": "opaque"
}
}
}
],
"unevaluatedProperties": false
},
"typeDescriptor": {
"oneOf": [
{
"$ref": "#/$defs/structDescriptor"
},
{
"$ref": "#/$defs/unionDescriptor"
},
{
"$ref": "#/$defs/enumDescriptor"
},
{
"$ref": "#/$defs/packedDescriptor"
},
{
"$ref": "#/$defs/aliasDescriptor"
},
{
"$ref": "#/$defs/opaqueDescriptor"
}
]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("terminal_options");
const lib = @import("../lib/main.zig");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const assert = @import("../quirks.zig").inlineAssert;
@@ -2144,6 +2145,51 @@ pub const Cell = packed struct(u64) {
prompt = 2,
};
/// Metadata for the C representation. All physical bit offsets and
/// widths are reflected from Cell.
pub const CLayout = lib.Packed(Cell, .{ .fields = .{
.content_tag = .{ .type_name = "GhosttyCellContentTag" },
.content = .{ .encoding = .{ .tagged_union = lib.PackedTaggedUnion(
Cell,
.content,
.content_tag,
.{ .arms = .{
.codepoint = .{ .codepoint = lib.Packed(
@FieldType(@FieldType(Cell, "content"), "codepoint"),
.{ .fields = .{
.data = .{ .name = "codepoint" },
._pad = .{ .omit = true },
} },
) },
.codepoint_grapheme = .{ .codepoint = lib.Packed(
@FieldType(@FieldType(Cell, "content"), "codepoint"),
.{ .fields = .{
.data = .{ .name = "codepoint" },
._pad = .{ .omit = true },
} },
) },
.bg_color_palette = .{ .color_palette = lib.Packed(
@FieldType(@FieldType(Cell, "content"), "color_palette"),
.{ .fields = .{
.data = .{
.name = "index",
.type_name = "GhosttyColorPaletteIndex",
},
._pad = .{ .omit = true },
} },
) },
.bg_color_rgb = .{ .color_rgb = lib.Packed(
@FieldType(@FieldType(Cell, "content"), "color_rgb"),
.{},
) },
} },
) } },
.style_id = .{ .type_name = "GhosttyStyleId" },
.wide = .{ .type_name = "GhosttyCellWide" },
.semantic_content = .{ .type_name = "GhosttyCellSemanticContent" },
._padding = .{ .omit = true },
} });
/// The backing integer of this packed struct. Prefer this over
/// hardcoding the integer type so that code is resilient to the
/// size changing.

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;