diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e45793f22..a5216072c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Doxyfile b/Doxyfile index 1703e6fac..77eeb3d3c 100644 --- a/Doxyfile +++ b/Doxyfile @@ -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 diff --git a/build.zig b/build.zig index 1c56cadf8..5ce7e816b 100644 --- a/build.zig +++ b/build.zig @@ -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, diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index 81a033737..76c9b9bbd 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -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); diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index 9409ebc73..f7626383c 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -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; diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 728966f48..f6b5e2222 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -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; diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h index 0d009a781..2899989dd 100644 --- a/include/ghostty/vt/screen.h +++ b/include/ghostty/vt/screen.h @@ -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 */ diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index c66ee89d2..a0621945e 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -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 + * libghostty-vt ABI manifest JSON Schema. * * 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" } } + * } * } * } * } diff --git a/nix/devShell.nix b/nix/devShell.nix index c94892414..fba128a21 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -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 { diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index a3cfdcc98..b5d176160 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -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 ./ diff --git a/src/lib/main.zig b/src/lib/main.zig index 45cdb71d4..573007bd9 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -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; diff --git a/src/lib/packed.zig b/src/lib/packed.zig new file mode 100644 index 000000000..b210242d8 --- /dev/null +++ b/src/lib/packed.zig @@ -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)); +} diff --git a/src/lib/union.zig b/src/lib/union.zig index 63a34a8cb..728b13304 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -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); +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1e974cdd5..b935b505f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 8eca343c2..2ecc9c975 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -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.*; } diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 7dcca5f02..55d488158 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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; diff --git a/src/terminal/c/types-schema-verify.py b/src/terminal/c/types-schema-verify.py new file mode 100644 index 000000000..1a8e064dc --- /dev/null +++ b/src/terminal/c/types-schema-verify.py @@ -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() diff --git a/src/terminal/c/types.schema.json b/src/terminal/c/types.schema.json new file mode 100644 index 000000000..7d784153b --- /dev/null +++ b/src/terminal/c/types.schema.json @@ -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" + } + ] + } + } +} diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 681556666..578900a2b 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -1,29 +1,51 @@ -//! Comptime-generated metadata describing the layout of all C API -//! extern structs for the current target. +//! Comptime-generated ABI metadata for the public libghostty-vt C types. //! -//! This is embedded in the binary as a const string and exposed via -//! `ghostty_type_json` so that WASM (and other FFI) consumers can -//! build structs without hardcoding byte offsets. +//! The manifest is embedded in the library and returned by +//! `ghostty_type_json`. It is intended for FFI consumers that cannot use the +//! C headers directly, most notably WebAssembly hosts. Its format is defined +//! by `types.schema.json`. const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); -const color = @import("../color.zig"); -const sgr = @import("../sgr.zig"); -const color_c = @import("color.zig"); -const mouse_event = @import("mouse_event.zig"); -const point = @import("../point.zig"); -const size_report = @import("size_report.zig"); -const terminal = @import("terminal.zig"); +const color = @import("../color.zig"); +const clipboard = @import("../clipboard.zig"); +const device_status = @import("../device_status.zig"); +const focus_pkg = @import("../focus.zig"); +const formatter_pkg = @import("../formatter.zig"); +const modes_pkg = @import("../modes.zig"); +const mouse_pkg = @import("../mouse.zig"); +const page = @import("../page.zig"); +const point = @import("../point.zig"); +const Selection = @import("../Selection.zig"); +const sgr = @import("../sgr.zig"); + +const input_config = @import("../../input/config.zig"); +const input_key = @import("../../input/key.zig"); +const input_mouse = @import("../../input/mouse.zig"); + +const build_info = @import("build_info.zig"); +const cell = @import("cell.zig"); +const color_c = @import("color.zig"); const formatter = @import("formatter.zig"); -const selection = @import("selection.zig"); -const selection_gesture = @import("selection_gesture.zig"); -const render = @import("render.zig"); -const style_c = @import("style.zig"); -const kitty_graphics = @import("kitty_graphics.zig"); -const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); const io = @import("io.zig"); +const key_encode = @import("key_encode.zig"); +const kitty_graphics = @import("kitty_graphics.zig"); +const mouse_encode = @import("mouse_encode.zig"); +const mouse_event = @import("mouse_event.zig"); +const osc = @import("osc.zig"); +const render = @import("render.zig"); +const result = @import("result.zig"); +const row = @import("row.zig"); +const selection = @import("selection.zig"); +const selection_gesture = @import("selection_gesture.zig"); +const size_report = @import("size_report.zig"); +const snapshot = @import("snapshot.zig"); +const style = @import("style.zig"); const sys = @import("sys.zig"); +const terminal = @import("terminal.zig"); /// C: GhosttySurfacePosition pub const SurfacePosition = extern struct { @@ -37,318 +59,1093 @@ pub const Codepoints = extern struct { len: usize = 0, }; -/// All C API structs and their Ghostty C names. -pub const structs: std.StaticStringMap(StructInfo) = structs: { - @setEvalBranchQuota(10_000); - break :structs .initComptime(.{ - .{ "GhosttyAllocator", StructInfo.init(lib.alloc.Allocator) }, - .{ "GhosttyAllocatorVtable", StructInfo.init(lib.alloc.VTable) }, - .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) }, - .{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) }, - .{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) }, - .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, - .{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) }, - .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, - .{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) }, - .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, - .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, - .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, - .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, - .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, - .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, - .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, - .{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) }, - .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, - .{ "GhosttyTerminalSelectionFormatOptions", StructInfo.init(selection.FormatOptions) }, - .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, - .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, - .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, - .{ "GhosttyKittyGraphicsPlacementRenderInfo", StructInfo.init(kitty_graphics.PlacementRenderInfo) }, - .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, - .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, - .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, - .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, - .{ "GhosttyReader", StructInfo.init(io.Reader) }, - .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, - .{ "GhosttyRenderStateCursor", StructInfo.init(render.Cursor) }, - .{ "GhosttyRenderStateRowSelection", StructInfo.init(render.RowSelection) }, - .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, - .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, - .{ "GhosttySgrAttribute", StructInfo.init(sgr.Attribute.C) }, - .{ "GhosttySgrUnknown", StructInfo.init(sgr.Attribute.Unknown.C) }, - .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, - .{ "GhosttyString", StructInfo.init(lib.String) }, - .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, - .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, - .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, - .{ "GhosttySysImage", StructInfo.init(sys.Image) }, - .{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) }, - .{ "GhosttyTerminalModeConfig", StructInfo.init(terminal.ModeConfig) }, - .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, - .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, - .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, - .{ "GhosttyTerminalUnknownSequence", StructInfo.init(terminal.UnknownSequence.C) }, - .{ "GhosttyTerminalUnknownStringSequence", StructInfo.init(terminal.UnknownStringSequence) }, - .{ "GhosttyWriter", StructInfo.init(io.Writer) }, - }); +const TypeDecl = struct { + name: []const u8, + T: type, + kind: Kind, + prefix: []const u8 = "", + sentinel_suffix: []const u8 = "MAX_VALUE", + alias_type: []const u8 = "", + tagged_union: ?TaggedUnion = null, + union_field_names_T: ?type = null, + + const Kind = enum { + @"struct", + @"union", + @"enum", + @"packed", + alias, + @"opaque", + }; + + const TaggedUnion = struct { + tag_field: []const u8, + value_field: []const u8, + arm_source: ArmSource, + + const ArmSource = enum { + fields, + generated, + }; + }; + + fn initStruct(comptime name: []const u8, comptime T: type) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"struct" }; + } + + fn initTaggedStruct( + comptime name: []const u8, + comptime T: type, + comptime tag_field: []const u8, + comptime value_field: []const u8, + comptime arm_source: TaggedUnion.ArmSource, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"struct", + .tagged_union = .{ + .tag_field = tag_field, + .value_field = value_field, + .arm_source = arm_source, + }, + }; + } + + fn initUnion( + comptime name: []const u8, + comptime T: type, + comptime field_names_T: ?type, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"union", + .union_field_names_T = field_names_T, + }; + } + + fn initEnum( + comptime name: []const u8, + comptime T: type, + comptime prefix: []const u8, + ) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"enum", .prefix = prefix }; + } + + fn initEnumSentinel( + comptime name: []const u8, + comptime T: type, + comptime prefix: []const u8, + comptime sentinel_suffix: []const u8, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"enum", + .prefix = prefix, + .sentinel_suffix = sentinel_suffix, + }; + } + + fn initPacked(comptime name: []const u8, comptime T: type) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"packed" }; + } + + fn initAlias( + comptime name: []const u8, + comptime T: type, + comptime alias_type: []const u8, + ) TypeDecl { + return .{ .name = name, .T = T, .kind = .alias, .alias_type = alias_type }; + } + + fn initOpaque(comptime name: []const u8) TypeDecl { + return .{ .name = name, .T = *anyopaque, .kind = .@"opaque" }; + } }; -/// The comptime-generated JSON string of all structs. +/// Public C types. Names and enum prefixes are intentionally explicit because +/// Zig identifiers are not the C API contract. +const type_decls = [_]TypeDecl{ + .initStruct("GhosttyAllocator", lib.alloc.Allocator), + .initStruct("GhosttyAllocatorVtable", lib.alloc.VTable), + .initStruct("GhosttyBuffer", lib.Buffer), + .initStruct("GhosttyCellsView", cell.CellsView), + .initStruct("GhosttyClipboardContent", terminal.ClipboardContent), + .initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite), + .initStruct("GhosttyCodepoints", Codepoints), + .initStruct("GhosttyColorPaletteMask", color_c.PaletteMask), + .initStruct("GhosttyColorRgb", color.RGB.C), + .initStruct("GhosttyColorX11Entry", color_c.X11Entry), + .initStruct("GhosttyDeviceAttributes", terminal.DeviceAttributes), + .initStruct("GhosttyDeviceAttributesPrimary", terminal.DeviceAttributes.Primary), + .initStruct("GhosttyDeviceAttributesSecondary", terminal.DeviceAttributes.Secondary), + .initStruct("GhosttyDeviceAttributesTertiary", terminal.DeviceAttributes.Tertiary), + .initStruct("GhosttyFormatterScreenExtra", formatter.ScreenOptions.Extra), + .initStruct("GhosttyFormatterTerminalExtra", formatter.TerminalOptions.Extra), + .initStruct("GhosttyFormatterTerminalOptions", formatter.TerminalOptions), + .initStruct("GhosttyGridRef", grid_ref.CGridRef), + .initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo), + .initStruct("GhosttyMouseEncoderSize", mouse_encode.Size), + .initStruct("GhosttyMousePosition", mouse_event.Position), + .initTaggedStruct("GhosttyPoint", point.Point.C, "tag", "value", .generated), + .initStruct("GhosttyPointCoordinate", point.Coordinate), + .initUnion("GhosttyPointValue", point.Point.CValue, point.Point.C), + .initStruct("GhosttyReader", io.Reader), + .initStruct("GhosttyRenderStateColors", render.Colors), + .initStruct("GhosttyRenderStateCursor", render.Cursor), + .initStruct("GhosttyRenderStateRowSelection", render.RowSelection), + .initStruct("GhosttySelection", selection.CSelection), + .initStruct("GhosttySelectionGestureBehaviors", selection_gesture.Behaviors), + .initStruct("GhosttySelectionGestureGeometry", selection_gesture.Geometry), + .initTaggedStruct("GhosttySgrAttribute", sgr.Attribute.C, "tag", "value", .generated), + .initStruct("GhosttySgrUnknown", sgr.Attribute.Unknown.C), + .initUnion("GhosttySgrAttributeValue", sgr.Attribute.CValue, sgr.Attribute.C), + .initStruct("GhosttySizeReportSize", size_report.Size), + .initStruct("GhosttyString", lib.String), + .initStruct("GhosttySurfacePosition", SurfacePosition), + .initStruct("GhosttyStyle", style.Style), + .initTaggedStruct("GhosttyStyleColor", style.Color, "tag", "value", .fields), + .initUnion("GhosttyStyleColorValue", style.ColorValue, null), + .initStruct("GhosttySysImage", sys.Image), + .initStruct("GhosttyTerminalDesktopNotification", terminal.DesktopNotification), + .initStruct("GhosttyTerminalModeConfig", terminal.ModeConfig), + .initStruct("GhosttyTerminalProgressReport", terminal.ProgressReport), + .initStruct("GhosttyTerminalScrollbar", terminal.TerminalScrollbar), + .initTaggedStruct("GhosttyTerminalScrollViewport", terminal.ScrollViewport, "tag", "value", .generated), + .initUnion( + "GhosttyTerminalScrollViewportValue", + terminal.ZigTerminal.ScrollViewport.CValue, + terminal.ZigTerminal.ScrollViewport.C, + ), + .initStruct("GhosttyTerminalSelectLineOptions", selection.SelectLineOptions), + .initStruct("GhosttyTerminalSelectWordBetweenOptions", selection.SelectWordBetweenOptions), + .initStruct("GhosttyTerminalSelectWordOptions", selection.SelectWordOptions), + .initStruct("GhosttyTerminalSelectionFormatOptions", selection.FormatOptions), + .initTaggedStruct("GhosttyTerminalUnknownSequence", terminal.UnknownSequence.C, "tag", "value", .generated), + .initStruct("GhosttyTerminalUnknownStringSequence", terminal.UnknownStringSequence), + .initUnion( + "GhosttyTerminalUnknownSequenceValue", + terminal.UnknownSequence.CValue, + terminal.UnknownSequence.C, + ), + .initStruct("GhosttyWriter", io.Writer), + + .initEnumSentinel("GhosttyResult", result.Result, "GHOSTTY_", "RESULT_MAX_VALUE"), + .initEnum("GhosttyBuildInfo", build_info.BuildInfo, "GHOSTTY_BUILD_INFO_"), + .initEnumSentinel("GhosttyOptimizeMode", build_info.OptimizeMode, "GHOSTTY_OPTIMIZE_", "MODE_MAX_VALUE"), + .initEnumSentinel("GhosttyCellContentTag", cell.ContentTag, "GHOSTTY_CELL_CONTENT_", "TAG_MAX_VALUE"), + .initEnum("GhosttyCellData", cell.CellData, "GHOSTTY_CELL_DATA_"), + .initEnum("GhosttyCellSemanticContent", cell.SemanticContent, "GHOSTTY_CELL_SEMANTIC_"), + .initEnum("GhosttyCellWide", cell.Wide, "GHOSTTY_CELL_WIDE_"), + .initEnum("GhosttyClipboardLocation", clipboard.Location, "GHOSTTY_CLIPBOARD_LOCATION_"), + .initEnum("GhosttyClipboardWriteResult", clipboard.WriteResult, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"), + .initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"), + .initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"), + .initEnum("GhosttyFormatterFormat", formatter_pkg.Format, "GHOSTTY_FORMATTER_FORMAT_"), + .initEnum("GhosttyKey", input_key.Key, "GHOSTTY_KEY_"), + .initEnum("GhosttyKeyAction", input_key.Action, "GHOSTTY_KEY_ACTION_"), + .initEnum("GhosttyKeyEncoderOption", key_encode.Option, "GHOSTTY_KEY_ENCODER_OPT_"), + .initEnum("GhosttyKittyGraphicsData", kitty_graphics.Data, "GHOSTTY_KITTY_GRAPHICS_DATA_"), + .initEnum("GhosttyKittyGraphicsImageData", kitty_graphics.ImageData, "GHOSTTY_KITTY_IMAGE_DATA_"), + .initEnum("GhosttyKittyGraphicsPlacementData", kitty_graphics.PlacementData, "GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_"), + .initEnum("GhosttyKittyGraphicsPlacementIteratorOption", kitty_graphics.PlacementIteratorOption, "GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_"), + .initEnum("GhosttyKittyImageCompression", kitty_graphics.ImageCompression, "GHOSTTY_KITTY_IMAGE_COMPRESSION_"), + .initEnum("GhosttyKittyImageFormat", kitty_graphics.ImageFormat, "GHOSTTY_KITTY_IMAGE_FORMAT_"), + .initEnum("GhosttyKittyPlacementLayer", kitty_graphics.PlacementLayer, "GHOSTTY_KITTY_PLACEMENT_LAYER_"), + .initEnum("GhosttyModeReportState", modes_pkg.Report.State, "GHOSTTY_MODE_REPORT_"), + .initEnum("GhosttyMouseAction", input_mouse.Action, "GHOSTTY_MOUSE_ACTION_"), + .initEnum("GhosttyMouseButton", input_mouse.Button, "GHOSTTY_MOUSE_BUTTON_"), + .initEnum("GhosttyMouseEncoderOption", mouse_encode.Option, "GHOSTTY_MOUSE_ENCODER_OPT_"), + .initEnum("GhosttyMouseFormat", mouse_pkg.Format, "GHOSTTY_MOUSE_FORMAT_"), + .initEnum("GhosttyMouseTrackingMode", mouse_pkg.Event, "GHOSTTY_MOUSE_TRACKING_"), + .initEnum("GhosttyOptionAsAlt", input_config.OptionAsAlt, "GHOSTTY_OPTION_AS_ALT_"), + .initEnum("GhosttyOscCommandData", osc.CommandData, "GHOSTTY_OSC_DATA_"), + .initEnumSentinel( + "GhosttyOscCommandType", + osc.CommandType, + "GHOSTTY_OSC_COMMAND_", + "TYPE_MAX_VALUE", + ), + .initEnum("GhosttyPointTag", point.Tag, "GHOSTTY_POINT_TAG_"), + .initEnum("GhosttyRenderStateCursorVisualStyle", render.CursorVisualStyle, "GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_"), + .initEnum("GhosttyRenderStateData", render.Data, "GHOSTTY_RENDER_STATE_DATA_"), + .initEnum("GhosttyRenderStateDirty", render.Dirty, "GHOSTTY_RENDER_STATE_DIRTY_"), + .initEnum("GhosttyRenderStateOption", render.SetOption, "GHOSTTY_RENDER_STATE_OPTION_"), + .initEnum("GhosttyRenderStateRowCellsData", render.RowCellsData, "GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_"), + .initEnum("GhosttyRenderStateRowData", render.RowData, "GHOSTTY_RENDER_STATE_ROW_DATA_"), + .initEnum("GhosttyRenderStateRowOption", render.RowOption, "GHOSTTY_RENDER_STATE_ROW_OPTION_"), + .initEnum("GhosttyRowData", row.RowData, "GHOSTTY_ROW_DATA_"), + .initEnum("GhosttyRowSemanticPrompt", row.SemanticPrompt, "GHOSTTY_ROW_SEMANTIC_"), + .initEnum("GhosttySelectionAdjust", Selection.Adjustment, "GHOSTTY_SELECTION_ADJUST_"), + .initEnum("GhosttySelectionGestureAutoscroll", selection_gesture.Autoscroll, "GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_"), + .initEnum("GhosttySelectionGestureBehavior", selection_gesture.Behavior, "GHOSTTY_SELECTION_GESTURE_BEHAVIOR_"), + .initEnum("GhosttySelectionGestureData", selection_gesture.Data, "GHOSTTY_SELECTION_GESTURE_DATA_"), + .initEnum("GhosttySelectionGestureEventOption", selection_gesture.EventOption, "GHOSTTY_SELECTION_GESTURE_EVENT_OPT_"), + .initEnum("GhosttySelectionGestureEventType", selection_gesture.EventType, "GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_"), + .initEnum("GhosttySelectionOrder", Selection.Order, "GHOSTTY_SELECTION_ORDER_"), + .initEnum("GhosttySgrAttributeTag", sgr.Attribute.Tag, "GHOSTTY_SGR_ATTR_"), + .initEnum("GhosttySgrUnderline", sgr.Attribute.Underline, "GHOSTTY_SGR_UNDERLINE_"), + .initEnumSentinel("GhosttySizeReportStyle", size_report.Style, "GHOSTTY_SIZE_REPORT_", "STYLE_MAX_VALUE"), + .initEnum("GhosttySnapshotDecoderData", snapshot.DecoderData, "GHOSTTY_SNAPSHOT_DECODER_DATA_"), + .initEnum("GhosttySnapshotDecoderOption", snapshot.DecoderOption, "GHOSTTY_SNAPSHOT_DECODER_OPT_"), + .initEnumSentinel("GhosttyStyleColorTag", style.ColorTag, "GHOSTTY_STYLE_COLOR_", "TAG_MAX_VALUE"), + .initEnum("GhosttySysLogLevel", sys.LogLevel, "GHOSTTY_SYS_LOG_LEVEL_"), + .initEnum("GhosttySysOption", sys.Option, "GHOSTTY_SYS_OPT_"), + .initEnum("GhosttyTerminalCompressionMode", terminal.CompressionMode, "GHOSTTY_TERMINAL_COMPRESSION_MODE_"), + .initEnum("GhosttyTerminalCompressionResult", terminal.CompressionResult, "GHOSTTY_TERMINAL_COMPRESSION_RESULT_"), + .initEnum("GhosttyTerminalCursorStyle", terminal.TerminalCursorStyle, "GHOSTTY_TERMINAL_CURSOR_STYLE_"), + .initEnum("GhosttyTerminalData", terminal.TerminalData, "GHOSTTY_TERMINAL_DATA_"), + .initEnum("GhosttyTerminalOption", terminal.Option, "GHOSTTY_TERMINAL_OPT_"), + .initEnum("GhosttyTerminalProgressState", terminal.ProgressState, "GHOSTTY_TERMINAL_PROGRESS_STATE_"), + .initEnum("GhosttyTerminalScreen", terminal.TerminalScreen, "GHOSTTY_TERMINAL_SCREEN_"), + .initEnum("GhosttyTerminalScrollViewportTag", terminal.ZigTerminal.ScrollViewport.Tag, "GHOSTTY_SCROLL_VIEWPORT_"), + .initEnum("GhosttyTerminalUnknownSequenceTag", terminal.UnknownSequence.Tag, "GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_"), + + .initPacked("GhosttyCell", page.Cell.CLayout), + .initAlias("GhosttyColorPaletteIndex", u8, "u8"), + .initAlias("GhosttyKittyKeyFlags", u8, "u8"), + .initAlias("GhosttyMode", u16, "u16"), + .initAlias("GhosttyMods", u16, "u16"), + .initAlias("GhosttyRow", u64, "u64"), + .initAlias("GhosttyStyleId", u16, "u16"), + + .initOpaque("GhosttyFormatter"), + .initOpaque("GhosttyKeyEncoder"), + .initOpaque("GhosttyKeyEvent"), + .initOpaque("GhosttyKittyGraphics"), + .initOpaque("GhosttyKittyGraphicsImage"), + .initOpaque("GhosttyKittyGraphicsPlacementIterator"), + .initOpaque("GhosttyMouseEncoder"), + .initOpaque("GhosttyMouseEvent"), + .initOpaque("GhosttyOscCommand"), + .initOpaque("GhosttyOscParser"), + .initOpaque("GhosttyRenderState"), + .initOpaque("GhosttyRenderStateRowCells"), + .initOpaque("GhosttyRenderStateRowIterator"), + .initOpaque("GhosttySelectionGesture"), + .initOpaque("GhosttySelectionGestureEvent"), + .initOpaque("GhosttySgrParser"), + .initOpaque("GhosttySnapshotDecoder"), + .initOpaque("GhosttyTerminal"), + .initOpaque("GhosttyTrackedGridRef"), +}; + +comptime { + @setEvalBranchQuota(100_000); + for (type_decls, 0..) |decl, i| { + for (type_decls[0..i]) |previous| { + if (std.mem.eql(u8, decl.name, previous.name)) + @compileError("duplicate public ABI type: " ++ decl.name); + } + } +} + pub const json: [:0]const u8 = json: { - @setEvalBranchQuota(200_000); + @setEvalBranchQuota(1_000_000); var counter: std.Io.Writer.Discarding = .init(&.{}); - jsonWriteAll(&counter.writer) catch unreachable; + Json.writeAll(&counter.writer) catch unreachable; var buf: [counter.count:0]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - jsonWriteAll(&writer) catch unreachable; + Json.writeAll(&writer) catch unreachable; const final = buf; break :json final[0..writer.end :0]; }; -/// 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() callconv(lib.calling_conv) [*:0]const u8 { 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, +const Json = struct { + fn writeAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { + var jws: std.json.Stringify = .{ .writer = writer }; + try jws.beginObject(); + try jws.objectField("schema"); + try jws.write(1); - pub const FieldInfo = struct { - name: []const u8, - offset: usize, - size: usize, - type: []const u8, - }; + try jws.objectField("abi"); + try jws.beginObject(); + try jws.objectField("target"); + try jws.write(@tagName(builtin.target.cpu.arch)); + try jws.objectField("os"); + try jws.write(@tagName(builtin.target.os.tag)); + try jws.objectField("environment"); + try jws.write(@tagName(builtin.target.abi)); + try jws.objectField("pointer_size"); + try jws.write(@sizeOf(*anyopaque)); + try jws.objectField("usize_size"); + try jws.write(@sizeOf(usize)); + try jws.objectField("endian"); + try jws.write(@tagName(builtin.target.cpu.arch.endian())); + try jws.endObject(); - 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; - }; + try jws.objectField("library_version"); + try jws.write(build_options.version_string); + try jws.objectField("commit"); + if (build_options.version_build) |commit| try jws.write(commit) else try jws.write(null); + try jws.objectField("dirty"); + try jws.write(null); - return .{ - .name = @typeName(T), - .size = @sizeOf(T), - .@"align" = @alignOf(T), - .fields = &field_infos, - }; + try jws.objectField("types"); + try jws.beginObject(); + inline for (type_decls) |decl| { + try jws.objectField(decl.name); + try writeType(decl, &jws); } + try jws.endObject(); + try jws.endObject(); } - pub fn jsonStringify( - self: *const StructInfo, - jws: anytype, + fn writeType(comptime decl: TypeDecl, jws: *std.json.Stringify) std.Io.Writer.Error!void { + try jws.beginObject(); + try jws.objectField("kind"); + try jws.write(@tagName(decl.kind)); + + switch (decl.kind) { + .@"struct", .@"union" => { + try writeSizeAlign(decl.T, jws); + try jws.objectField("fields"); + try jws.beginObject(); + if (decl.union_field_names_T != null) { + try writeMappedUnionFields(decl, jws); + } else { + const fields = switch (decl.kind) { + .@"struct" => @typeInfo(decl.T).@"struct".fields, + .@"union" => @typeInfo(decl.T).@"union".fields, + else => unreachable, + }; + inline for (fields) |field| { + try writeField( + decl, + field.name, + field.type, + if (decl.kind == .@"union") 0 else @offsetOf(decl.T, field.name), + jws, + ); + } + } + try jws.endObject(); + }, + .@"enum" => { + try writeSizeAlign(c_int, jws); + try jws.objectField("underlying"); + try jws.write("i32"); + try jws.objectField("prefix"); + try jws.write(decl.prefix); + try jws.objectField("values"); + try jws.beginObject(); + inline for (@typeInfo(decl.T).@"enum".fields) |field| { + try writeEnumObjectField(decl.name, field.name, jws); + try jws.write(field.value); + } + try jws.objectField(decl.sentinel_suffix); + try jws.write(std.math.maxInt(c_int)); + try jws.endObject(); + }, + .@"packed" => try writePackedType(decl.T, jws), + .alias => { + try writeSizeAlign(decl.T, jws); + try jws.objectField("type"); + try jws.write(decl.alias_type); + }, + .@"opaque" => try writeSizeAlign(*anyopaque, jws), + } + try jws.endObject(); + } + + fn writePackedType( + comptime Layout: type, + jws: *std.json.Stringify, ) std.Io.Writer.Error!void { + try writeSizeAlign(Layout.Zig, jws); + try jws.objectField("underlying"); + try jws.write(publicTypeName(Layout.Backing)); + try jws.objectField("bits"); 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 writePackedBits(Layout, jws); + try jws.endObject(); + } + + fn writePackedBits( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + inline for (@typeInfo(Layout.Zig).@"struct".fields) |field| { + const field_tag = @field(Layout.Field, field.name); + const name = comptime Layout.fieldName(field_tag) orelse continue; + const options = Layout.fieldOptions(field_tag); + + try jws.objectField(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.objectField("lsb"); + try jws.write(Layout.bitOffset(field_tag)); + try jws.objectField("width"); + try jws.write(Layout.bitWidth(field_tag)); + + switch (options.encoding) { + .scalar => { + try jws.objectField("type"); + try jws.write(options.type_name orelse publicTypeName(field.type)); + }, + .@"packed" => |Nested| { + try jws.objectField("kind"); + try jws.write("packed"); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Nested, jws); + try jws.endObject(); + }, + .tagged_union => |UnionLayout| try writePackedTaggedUnion(Layout, UnionLayout, jws), + } try jws.endObject(); } + } + + fn writePackedTaggedUnion( + comptime Layout: type, + comptime UnionLayout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try jws.objectField("kind"); + try jws.write("union"); + try jws.objectField("tag"); + try jws.write(Layout.fieldName(UnionLayout.tag_field).?); + try jws.objectField("arms"); + try jws.beginObject(); + + const tag_options = Layout.fieldOptions(UnionLayout.tag_field); + const tag_type_name = tag_options.type_name orelse publicTypeName(UnionLayout.Tag); + inline for (@typeInfo(UnionLayout.Tag).@"enum".fields) |tag| { + try writeEnumObjectField(tag_type_name, tag.name, jws); + const arm = UnionLayout.arm(@field(UnionLayout.Tag, tag.name)) orelse { + try jws.write(null); + continue; + }; + switch (arm) { + inline else => |ArmLayout| try writePackedArm(ArmLayout, jws), + } + } + + try jws.endObject(); + } + + fn writePackedArm( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try jws.beginObject(); + try jws.objectField("kind"); + try jws.write("packed"); + try jws.objectField("width"); + try jws.write(@bitSizeOf(Layout.Zig)); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Layout, jws); 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| { + fn writeField( + comptime decl: TypeDecl, + comptime name: []const u8, + comptime T: type, + comptime offset: usize, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { try jws.objectField(name); - try info.jsonStringify(&jws); + try jws.beginObject(); + try jws.objectField("offset"); + try jws.write(offset); + try jws.objectField("size"); + try jws.write(@sizeOf(T)); + try writeFieldType(decl.name, name, T, jws); + if (decl.tagged_union) |tagged| { + if (std.mem.eql(u8, name, tagged.value_field)) { + try jws.objectField("tag"); + try jws.write(tagged.tag_field); + try writeTaggedArms(decl, tagged, jws); + } + } + try jws.endObject(); } - try jws.endObject(); -} -fn typeName(comptime T: type) []const u8 { - return switch (@typeInfo(T)) { - .bool => "bool", - .float => |info| switch (info.bits) { - 32 => "f32", - 64 => "f64", - else => @compileError("unsupported float size"), - }, - .int => |info| switch (info.signedness) { - .signed => switch (info.bits) { + fn writeMappedUnionFields( + comptime decl: TypeDecl, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const FieldNames = decl.union_field_names_T.?; + const Tag = @FieldType(FieldNames, "tag"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + + inline for (tag_fields, 0..) |field, i| { + const name = comptime FieldNames.cFieldRename(@field(Tag, field.name)) orelse continue; + if (comptime mappedUnionFieldIsDuplicate(decl, i, name)) continue; + try writeField(decl, name, @FieldType(decl.T, field.name), 0, jws); + } + + if (@hasField(decl.T, "_padding")) + try writeField(decl, "_padding", @FieldType(decl.T, "_padding"), 0, jws); + } + + fn mappedUnionFieldIsDuplicate( + comptime decl: TypeDecl, + comptime index: usize, + comptime name: []const u8, + ) bool { + const FieldNames = decl.union_field_names_T.?; + const Tag = @FieldType(FieldNames, "tag"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + const T = @FieldType(decl.T, tag_fields[index].name); + + inline for (tag_fields[0..index]) |previous| { + const previous_name = FieldNames.cFieldRename(@field(Tag, previous.name)) orelse continue; + if (std.mem.eql(u8, previous_name, name)) { + if (@FieldType(decl.T, previous.name) != T) + @compileError("tagged union metadata maps different field types to " ++ name); + return true; + } + } + + return false; + } + + fn writeSizeAlign(comptime T: type, jws: *std.json.Stringify) std.Io.Writer.Error!void { + try jws.objectField("size"); + try jws.write(@sizeOf(T)); + try jws.objectField("align"); + try jws.write(@alignOf(T)); + } + + fn writeFieldType( + comptime owner: []const u8, + comptime field_name: []const u8, + comptime T: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + if (comptime std.mem.eql(u8, owner, "GhosttyCellsView") and + std.mem.eql(u8, field_name, "ptr")) + { + try writePointerTypeNamed(T, true, "GhosttyCell", jws); + return; + } + if (comptime std.mem.eql(u8, owner, "GhosttyGridRef") and + std.mem.eql(u8, field_name, "node")) + { + try writePointerTypeNamed(T, true, "opaque", jws); + return; + } + + if (comptime fieldTypeOverride(owner, field_name)) |name| { + try jws.objectField("type"); + try jws.write(name); + return; + } + + switch (@typeInfo(T)) { + .array => |info| { + try jws.objectField("type"); + try jws.write("array"); + try jws.objectField("elem"); + try jws.write(publicTypeName(info.child)); + try jws.objectField("count"); + try jws.write(info.len); + }, + .optional => |info| try writePointerType(info.child, true, jws), + .pointer => try writePointerType(T, false, jws), + else => { + try jws.objectField("type"); + try jws.write(publicTypeName(T)); + }, + } + } + + fn writePointerType( + comptime T: type, + comptime nullable: bool, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const info = @typeInfo(T).pointer; + try jws.objectField("type"); + try jws.write("pointer"); + try jws.objectField("elem"); + try jws.write(publicTypeName(info.child)); + try jws.objectField("const"); + try jws.write(info.is_const); + if (nullable) { + try jws.objectField("nullable"); + try jws.write(true); + } + } + + fn writePointerTypeNamed( + comptime T: type, + comptime nullable: bool, + comptime elem: []const u8, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const pointer_type = switch (@typeInfo(T)) { + .optional => |info| info.child, + .pointer => T, + else => unreachable, + }; + const info = @typeInfo(pointer_type).pointer; + try jws.objectField("type"); + try jws.write("pointer"); + try jws.objectField("elem"); + try jws.write(elem); + try jws.objectField("const"); + try jws.write(info.is_const); + if (nullable) { + try jws.objectField("nullable"); + try jws.write(true); + } + } + + fn fieldTypeOverride(comptime owner: []const u8, comptime field_name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, field_name, "value")) { + if (std.mem.eql(u8, owner, "GhosttyPoint")) return "GhosttyPointValue"; + if (std.mem.eql(u8, owner, "GhosttySgrAttribute")) return "GhosttySgrAttributeValue"; + if (std.mem.eql(u8, owner, "GhosttyStyleColor")) return "GhosttyStyleColorValue"; + if (std.mem.eql(u8, owner, "GhosttyTerminalScrollViewport")) return "GhosttyTerminalScrollViewportValue"; + if (std.mem.eql(u8, owner, "GhosttyTerminalUnknownSequence")) return "GhosttyTerminalUnknownSequenceValue"; + } + if (std.mem.eql(u8, owner, "GhosttyStyleColorValue") and std.mem.eql(u8, field_name, "palette")) + return "GhosttyColorPaletteIndex"; + if (std.mem.eql(u8, owner, "GhosttySgrAttributeValue")) { + if (std.mem.eql(u8, field_name, "underline")) return "GhosttySgrUnderline"; + if (std.mem.endsWith(u8, field_name, "_8") or std.mem.endsWith(u8, field_name, "_256")) + return "GhosttyColorPaletteIndex"; + } + if (std.mem.eql(u8, owner, "GhosttyTerminalModeConfig") and std.mem.eql(u8, field_name, "mode")) + return "GhosttyMode"; + return null; + } + + fn writeTaggedArms( + comptime decl: TypeDecl, + comptime tagged: TypeDecl.TaggedUnion, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const Tag = @FieldType(decl.T, tagged.tag_field); + try jws.objectField("arms"); + try jws.beginObject(); + inline for (@typeInfo(Tag).@"enum".fields) |field| { + try writeEnumObjectField(publicTypeName(Tag), field.name, jws); + if (comptime taggedArm(decl, field.name)) |arm| try jws.write(arm) else try jws.write(null); + } + try jws.endObject(); + } + + fn taggedArm(comptime decl: TypeDecl, comptime tag_name: []const u8) ?[]const u8 { + const tagged = decl.tagged_union.?; + const Tag = @FieldType(decl.T, tagged.tag_field); + if (tagged.arm_source == .generated) + return decl.T.cFieldRename(@field(Tag, tag_name)); + + const Value = @FieldType(decl.T, tagged.value_field); + if (!@hasField(Value, tag_name)) return null; + return if (@sizeOf(@FieldType(Value, tag_name)) == 0) null else tag_name; + } + + fn publicTypeName(comptime T: type) []const u8 { + inline for (type_decls) |decl| switch (decl.kind) { + .@"struct", .@"union", .@"enum" => if (T == decl.T) return decl.name, + .@"packed", .alias, .@"opaque" => {}, + }; + return switch (@typeInfo(T)) { + .bool => "bool", + .float => |info| switch (info.bits) { + 32 => "f32", + 64 => "f64", + else => "opaque", + }, + .int => |info| intName(info.signedness, info.bits), + .comptime_int => "comptime_int", + .void => "void", + .@"opaque" => "opaque", + .@"fn" => "function", + .@"enum" => "enum", + .@"struct" => "struct", + .@"union" => "union", + else => "opaque", + }; + } + + fn intName(comptime signedness: std.builtin.Signedness, comptime bits: u16) []const u8 { + return switch (signedness) { + .signed => switch (bits) { 8 => "i8", 16 => "i16", 32 => "i32", 64 => "i64", - else => @compileError("unsupported signed int size"), + else => std.fmt.comptimePrint("i{d}", .{bits}), }, - .unsigned => switch (info.bits) { + .unsigned => switch (bits) { 8 => "u8", 16 => "u16", 32 => "u32", 64 => "u64", - else => @compileError("unsupported unsigned int size"), + else => std.fmt.comptimePrint("u{d}", .{bits}), }, - }, - .@"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, - .{}, - ); + fn writeUpperObjectField(comptime name: []const u8, jws: *std.json.Stringify) std.Io.Writer.Error!void { + var upper: [name.len]u8 = undefined; + for (name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); + try jws.objectField(&upper); + } + + fn writeEnumObjectField( + comptime enum_name: []const u8, + comptime zig_name: []const u8, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + if (std.mem.eql(u8, enum_name, "GhosttyKey") and std.mem.startsWith(u8, zig_name, "key_")) + return writeUpperObjectField(zig_name["key_".len..], jws); + if (std.mem.eql(u8, enum_name, "GhosttySgrAttributeTag")) { + if (std.mem.eql(u8, zig_name, "256_underline_color")) return jws.objectField("UNDERLINE_COLOR_256"); + if (std.mem.eql(u8, zig_name, "8_bg")) return jws.objectField("BG_8"); + if (std.mem.eql(u8, zig_name, "8_fg")) return jws.objectField("FG_8"); + if (std.mem.eql(u8, zig_name, "8_bright_bg")) return jws.objectField("BRIGHT_BG_8"); + if (std.mem.eql(u8, zig_name, "8_bright_fg")) return jws.objectField("BRIGHT_FG_8"); + if (std.mem.eql(u8, zig_name, "256_bg")) return jws.objectField("BG_256"); + if (std.mem.eql(u8, zig_name, "256_fg")) return jws.objectField("FG_256"); + } + if (std.mem.eql(u8, enum_name, "GhosttyTerminalOption") and std.mem.eql(u8, zig_name, "size_cb")) + return jws.objectField("SIZE"); + return writeUpperObjectField(zig_name, jws); + } + + fn isBuiltinType(name: []const u8) bool { + const builtins = [_][]const u8{ + "array", "bool", "f32", "f64", "function", "i8", "i16", "i32", + "i64", "opaque", "pointer", "u8", "u16", "u32", "u64", "void", + }; + for (builtins) |builtin_name| { + if (std.mem.eql(u8, name, builtin_name)) return true; + } + if (name.len > 1 and (name[0] == 'i' or name[0] == 'u') and + name[1] >= '1' and name[1] <= '9') + { + for (name[2..]) |c| if (!std.ascii.isDigit(c)) return false; + return true; + } + return false; + } +}; + +test "manifest parses and is versioned" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); - const root = parsed.value.object; + try std.testing.expectEqual(@as(i64, 1), root.get("schema").?.integer); + try std.testing.expect(root.contains("abi")); + try std.testing.expect(root.contains("library_version")); + const manifest_types = root.get("types").?.object; + try std.testing.expectEqual(type_decls.len, manifest_types.count()); + inline for (type_decls) |decl| try std.testing.expect(manifest_types.contains(decl.name)); - // Verify we have every public struct declared by the C API headers. - const expected_structs = [_][]const u8{ - "GhosttyAllocator", - "GhosttyAllocatorVtable", - "GhosttyBuffer", - "GhosttyClipboardContent", - "GhosttyClipboardWrite", - "GhosttyCodepoints", - "GhosttyColorPaletteMask", - "GhosttyColorRgb", - "GhosttyColorX11Entry", - "GhosttyDeviceAttributes", - "GhosttyDeviceAttributesPrimary", - "GhosttyDeviceAttributesSecondary", - "GhosttyDeviceAttributesTertiary", - "GhosttyFormatterScreenExtra", - "GhosttyFormatterTerminalExtra", - "GhosttyFormatterTerminalOptions", - "GhosttyGridRef", - "GhosttyKittyGraphicsPlacementRenderInfo", - "GhosttyMouseEncoderSize", - "GhosttyMousePosition", - "GhosttyPoint", - "GhosttyPointCoordinate", - "GhosttyReader", - "GhosttyRenderStateColors", - "GhosttyRenderStateCursor", - "GhosttyRenderStateRowSelection", - "GhosttySelection", - "GhosttySelectionGestureBehaviors", - "GhosttySelectionGestureGeometry", - "GhosttySgrAttribute", - "GhosttySgrUnknown", - "GhosttySizeReportSize", - "GhosttyString", - "GhosttyStyle", - "GhosttyStyleColor", - "GhosttySurfacePosition", - "GhosttySysImage", - "GhosttyTerminalDesktopNotification", - "GhosttyTerminalModeConfig", - "GhosttyTerminalProgressReport", - "GhosttyTerminalScrollbar", - "GhosttyTerminalScrollViewport", - "GhosttyTerminalSelectLineOptions", - "GhosttyTerminalSelectWordBetweenOptions", - "GhosttyTerminalSelectWordOptions", - "GhosttyTerminalSelectionFormatOptions", - "GhosttyTerminalUnknownSequence", - "GhosttyTerminalUnknownStringSequence", - "GhosttyWriter", - }; - try std.testing.expectEqual(expected_structs.len, root.count()); - for (expected_structs) |name| { - try std.testing.expect(root.contains(name)); - } - - const clipboard_content = root.get("GhosttyClipboardContent").?.object; - const clipboard_content_fields = clipboard_content.get("fields").?.object; - try std.testing.expect(clipboard_content_fields.contains("mime")); - try std.testing.expect(clipboard_content_fields.contains("data")); - - const clipboard_write = root.get("GhosttyClipboardWrite").?.object; - const clipboard_write_fields = clipboard_write.get("fields").?.object; - try std.testing.expect(clipboard_write_fields.contains("size")); - try std.testing.expect(clipboard_write_fields.contains("location")); - try std.testing.expect(clipboard_write_fields.contains("contents")); - try std.testing.expect(clipboard_write_fields.contains("contents_len")); - - const unknown_sequence = root.get("GhosttyTerminalUnknownSequence").?.object; - const unknown_sequence_fields = unknown_sequence.get("fields").?.object; - try std.testing.expect(unknown_sequence_fields.contains("tag")); - try std.testing.expect(unknown_sequence_fields.contains("value")); - - const unknown_string = root.get("GhosttyTerminalUnknownStringSequence").?.object; - const unknown_string_fields = unknown_string.get("fields").?.object; - try std.testing.expect(unknown_string_fields.contains("truncated")); - try std.testing.expect(unknown_string_fields.contains("content")); - - const render_cursor_fields = root.get("GhosttyRenderStateCursor").?.object + const cursor_fields = manifest_types.get("GhosttyRenderStateCursor").?.object .get("fields").?.object; - try std.testing.expect(render_cursor_fields.contains("size")); - try std.testing.expect(render_cursor_fields.contains("viewport_has_value")); - try std.testing.expect(render_cursor_fields.contains("viewport_x")); - try std.testing.expect(render_cursor_fields.contains("viewport_y")); - try std.testing.expect(render_cursor_fields.contains("wide_tail")); - try std.testing.expect(render_cursor_fields.contains("visible")); - try std.testing.expect(render_cursor_fields.contains("blinking")); - try std.testing.expect(render_cursor_fields.contains("password_input")); - try std.testing.expect(render_cursor_fields.contains("visual_style")); - - const reader_fields = root.get("GhosttyReader").?.object - .get("fields").?.object; - try std.testing.expect(reader_fields.contains("read")); - try std.testing.expect(reader_fields.contains("userdata")); - - const writer_fields = root.get("GhosttyWriter").?.object - .get("fields").?.object; - try std.testing.expect(writer_fields.contains("write")); - try std.testing.expect(writer_fields.contains("userdata")); - - try std.testing.expect(!root.contains("GhosttyTerminalOptions")); + try std.testing.expect(cursor_fields.contains("size")); + try std.testing.expect(cursor_fields.contains("viewport_has_value")); + try std.testing.expect(cursor_fields.contains("viewport_x")); + try std.testing.expect(cursor_fields.contains("viewport_y")); + try std.testing.expect(cursor_fields.contains("wide_tail")); + try std.testing.expect(cursor_fields.contains("visible")); + try std.testing.expect(cursor_fields.contains("blinking")); + try std.testing.expect(cursor_fields.contains("password_input")); + try std.testing.expect(cursor_fields.contains("visual_style")); } -test "struct sizes are non-zero" { - const parsed = try std.json.parseFromSlice( - std.json.Value, - std.testing.allocator, - json, - .{}, - ); +test "manifest describes enums, arrays, and tagged unions" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; - 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); + const data = manifest_types.get("GhosttyRenderStateData").?.object; + try std.testing.expectEqualStrings("enum", data.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @intFromEnum(render.Data.dirty)), data.get("values").?.object.get("DIRTY").?.integer); + + const colors = manifest_types.get("GhosttyRenderStateColors").?.object; + const palette = colors.get("fields").?.object.get("palette").?.object; + try std.testing.expectEqualStrings("array", palette.get("type").?.string); + try std.testing.expectEqualStrings("GhosttyColorRgb", palette.get("elem").?.string); + try std.testing.expectEqual(@as(i64, 256), palette.get("count").?.integer); + + const style_color = manifest_types.get("GhosttyStyleColor").?.object; + const value = style_color.get("fields").?.object.get("value").?.object; + try std.testing.expectEqualStrings("GhosttyStyleColorValue", value.get("type").?.string); + try std.testing.expectEqualStrings("tag", value.get("tag").?.string); + try std.testing.expectEqualStrings("palette", value.get("arms").?.object.get("PALETTE").?.string); + try std.testing.expect(value.get("arms").?.object.get("NONE").? == .null); +} + +test "manifest describes the complete packed cell layout" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + const descriptor = manifest_types.get("GhosttyCell").?.object; + + try std.testing.expectEqualStrings("packed", descriptor.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @sizeOf(page.Cell.CLayout.Zig)), descriptor.get("size").?.integer); + try std.testing.expectEqualStrings("u64", descriptor.get("underlying").?.string); + + const bits = descriptor.get("bits").?.object; + inline for (@typeInfo(page.Cell.CLayout.Zig).@"struct".fields) |field| { + const field_tag = @field(page.Cell.CLayout.Field, field.name); + const name = comptime page.Cell.CLayout.fieldName(field_tag); + if (name) |public_name| { + const bit = bits.get(public_name).?.object; + try std.testing.expectEqual( + @as(i64, @intCast(@bitOffsetOf(page.Cell.CLayout.Zig, field.name))), + bit.get("lsb").?.integer, + ); + try std.testing.expectEqual( + @as(i64, @intCast(@bitSizeOf(field.type))), + bit.get("width").?.integer, + ); + } else { + try std.testing.expect(!bits.contains(field.name)); + } + } + + const content = bits.get("content").?.object; + try std.testing.expectEqualStrings("union", content.get("kind").?.string); + try std.testing.expectEqualStrings("content_tag", content.get("tag").?.string); + const arms = content.get("arms").?.object; + + const Content = @FieldType(page.Cell.CLayout.Zig, "content"); + const Codepoint = @FieldType(Content, "codepoint"); + const codepoint = arms.get("CODEPOINT").?.object; + const grapheme = arms.get("CODEPOINT_GRAPHEME").?.object; + try expectPackedArmField(codepoint, "codepoint", Codepoint, "data", "u21"); + try expectPackedArmField(grapheme, "codepoint", Codepoint, "data", "u21"); + try expectPackedArmField( + arms.get("BG_COLOR_PALETTE").?.object, + "index", + @FieldType(Content, "color_palette"), + "data", + "GhosttyColorPaletteIndex", + ); + + const rgb = arms.get("BG_COLOR_RGB").?.object; + const Rgb = @FieldType(Content, "color_rgb"); + inline for (@typeInfo(Rgb).@"struct".fields) |field| + try expectPackedArmField(rgb, field.name, Rgb, field.name, "u8"); +} + +test "manifest packed cell layouts decode real values" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + const cell_bits = manifest_types.get("GhosttyCell").?.object.get("bits").?.object; + + var codepoint = page.Cell.CLayout.Zig.init('A'); + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(codepoint), "codepoint", 'A'); + + codepoint.content_tag = .codepoint_grapheme; + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(codepoint), "codepoint", 'A'); + + var palette: page.Cell.CLayout.Zig = @bitCast(@as(u64, 0)); + palette.content_tag = .bg_color_palette; + palette.content = .{ .color_palette = .{ .data = 173 } }; + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(palette), "index", 173); + + var rgb: page.Cell.CLayout.Zig = @bitCast(@as(u64, 0)); + rgb.content_tag = .bg_color_rgb; + rgb.content = .{ .color_rgb = .{ .r = 0x12, .g = 0x34, .b = 0x56 } }; + const rgb_arm = activeCellContentArm(manifest_types, cell_bits, @bitCast(rgb)); + const content = extractManifestBits(@bitCast(rgb), cell_bits.get("content").?.object); + try std.testing.expectEqual(@as(u64, 0x12), extractManifestBits(content, rgb_arm.get("bits").?.object.get("r").?.object)); + try std.testing.expectEqual(@as(u64, 0x34), extractManifestBits(content, rgb_arm.get("bits").?.object.get("g").?.object)); + try std.testing.expectEqual(@as(u64, 0x56), extractManifestBits(content, rgb_arm.get("bits").?.object.get("b").?.object)); +} + +test "manifest uses public enum names" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + + const cell_content = manifest_types.get("GhosttyCellContentTag").?.object.get("values").?.object; + try std.testing.expect(cell_content.contains("TAG_MAX_VALUE")); + try std.testing.expect(!cell_content.contains("MAX_VALUE")); + + const sgr_values = manifest_types.get("GhosttySgrAttributeTag").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 9), sgr_values.get("UNDERLINE_COLOR_256").?.integer); + try std.testing.expectEqual(@as(i64, 23), sgr_values.get("BG_8").?.integer); + try std.testing.expect(!sgr_values.contains("256_UNDERLINE_COLOR")); + + const sgr_arms = manifest_types.get("GhosttySgrAttribute").?.object + .get("fields").?.object.get("value").?.object.get("arms").?.object; + try std.testing.expectEqualStrings("underline_color_256", sgr_arms.get("UNDERLINE_COLOR_256").?.string); + try std.testing.expectEqualStrings("bg_8", sgr_arms.get("BG_8").?.string); + + const terminal_values = manifest_types.get("GhosttyTerminalOption").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 6), terminal_values.get("SIZE").?.integer); + try std.testing.expect(!terminal_values.contains("SIZE_CB")); + + const osc_values = manifest_types.get("GhosttyOscCommandType").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 22), osc_values.get("KITTY_TEXT_SIZING").?.integer); + try std.testing.expectEqual(@as(i64, 23), osc_values.get("KITTY_CLIPBOARD_PROTOCOL").?.integer); + try std.testing.expectEqual(@as(i64, 24), osc_values.get("KITTY_DND_PROTOCOL").?.integer); + try std.testing.expectEqual(@as(i64, 25), osc_values.get("CONTEXT_SIGNAL").?.integer); + try std.testing.expect(osc_values.contains("TYPE_MAX_VALUE")); + try std.testing.expect(!osc_values.contains("MAX_VALUE")); + + const key_values = manifest_types.get("GhosttyKey").?.object.get("values").?.object; + try std.testing.expect(key_values.contains("A")); + try std.testing.expect(!key_values.contains("KEY_A")); + + const mode_values = manifest_types.get("GhosttyModeReportState").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 0), mode_values.get("NOT_RECOGNIZED").?.integer); + try std.testing.expectEqual(@as(i64, 4), mode_values.get("PERMANENTLY_RESET").?.integer); +} + +test "manifest named references resolve" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + + var type_iterator = manifest_types.iterator(); + while (type_iterator.next()) |type_entry| { + const descriptor = type_entry.value_ptr.object; + if (descriptor.get("bits")) |bits_value| { + try expectManifestBitsValid( + manifest_types, + bits_value.object, + @intCast(descriptor.get("size").?.integer * 8), + ); + } + const fields_value = descriptor.get("fields") orelse continue; + var field_iterator = fields_value.object.iterator(); + while (field_iterator.next()) |field_entry| { + const field = field_entry.value_ptr.object; + const field_type = field.get("type").?.string; + if (!Json.isBuiltinType(field_type)) + try std.testing.expect(manifest_types.contains(field_type)); + + if (field.get("elem")) |elem_value| { + const elem = elem_value.string; + if (!Json.isBuiltinType(elem)) + try std.testing.expect(manifest_types.contains(elem)); + } + + if (field.get("arms")) |arms_value| { + const union_descriptor = manifest_types.get(field_type).?.object; + const union_fields = union_descriptor.get("fields").?.object; + const tag_field_name = field.get("tag").?.string; + const tag_type_name = fields_value.object.get(tag_field_name).?.object.get("type").?.string; + const tag_values = manifest_types.get(tag_type_name).?.object.get("values").?.object; + var arm_iterator = arms_value.object.iterator(); + while (arm_iterator.next()) |arm_entry| { + try std.testing.expect(tag_values.contains(arm_entry.key_ptr.*)); + if (arm_entry.value_ptr.* == .null) continue; + try std.testing.expect(union_fields.contains(arm_entry.value_ptr.string)); + } + } + } + } +} + +fn expectPackedArmField( + arm: std.json.ObjectMap, + manifest_name: []const u8, + comptime T: type, + comptime zig_name: []const u8, + expected_type: []const u8, +) !void { + try std.testing.expectEqualStrings("packed", arm.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @bitSizeOf(T)), arm.get("width").?.integer); + const bit = arm.get("bits").?.object.get(manifest_name).?.object; + try std.testing.expectEqual(@as(i64, @bitOffsetOf(T, zig_name)), bit.get("lsb").?.integer); + try std.testing.expectEqual(@as(i64, @bitSizeOf(@FieldType(T, zig_name))), bit.get("width").?.integer); + try std.testing.expectEqualStrings(expected_type, bit.get("type").?.string); +} + +fn extractManifestBits(value: u64, bit: std.json.ObjectMap) u64 { + const lsb: u6 = @intCast(bit.get("lsb").?.integer); + const width: u7 = @intCast(bit.get("width").?.integer); + const mask = if (width == 64) + std.math.maxInt(u64) + else + (@as(u64, 1) << @intCast(width)) - 1; + return (value >> lsb) & mask; +} + +fn activeCellContentArm( + manifest_types: std.json.ObjectMap, + cell_bits: std.json.ObjectMap, + raw: u64, +) std.json.ObjectMap { + const content_tag = cell_bits.get("content_tag").?.object; + const value = extractManifestBits(raw, content_tag); + const enum_values = manifest_types.get(content_tag.get("type").?.string).?.object + .get("values").?.object; + var iterator = enum_values.iterator(); + while (iterator.next()) |entry| { + if (entry.value_ptr.integer == value) + return cell_bits.get("content").?.object.get("arms").?.object + .get(entry.key_ptr.*).?.object; + } + unreachable; +} + +fn expectDecodedCellContent( + manifest_types: std.json.ObjectMap, + cell_bits: std.json.ObjectMap, + raw: u64, + field_name: []const u8, + expected: u64, +) !void { + const arm = activeCellContentArm(manifest_types, cell_bits, raw); + const content = extractManifestBits(raw, cell_bits.get("content").?.object); + try std.testing.expectEqual( + expected, + extractManifestBits(content, arm.get("bits").?.object.get(field_name).?.object), + ); +} + +fn expectManifestBitsValid( + manifest_types: std.json.ObjectMap, + bits: std.json.ObjectMap, + container_width: usize, +) !void { + var iterator = bits.iterator(); + while (iterator.next()) |entry| { + const bit = entry.value_ptr.object; + const lsb: usize = @intCast(bit.get("lsb").?.integer); + const width: usize = @intCast(bit.get("width").?.integer); + try std.testing.expect(lsb + width <= container_width); + + if (bit.get("type")) |type_value| { + const type_name = type_value.string; + try std.testing.expect(Json.isBuiltinType(type_name) or manifest_types.contains(type_name)); + continue; + } + + const kind = bit.get("kind").?.string; + if (std.mem.eql(u8, kind, "packed")) { + try expectManifestBitsValid(manifest_types, bit.get("bits").?.object, width); + continue; + } + + try std.testing.expectEqualStrings("union", kind); + const tag_name = bit.get("tag").?.string; + const tag = bits.get(tag_name).?.object; + const enum_values = manifest_types.get(tag.get("type").?.string).?.object + .get("values").?.object; + var arm_iterator = bit.get("arms").?.object.iterator(); + while (arm_iterator.next()) |arm_entry| { + try std.testing.expect(enum_values.contains(arm_entry.key_ptr.*)); + if (arm_entry.value_ptr.* == .null) continue; + const arm = arm_entry.value_ptr.object; + const arm_width: usize = @intCast(arm.get("width").?.integer); + try std.testing.expect(arm_width <= width); + try expectManifestBitsValid(manifest_types, arm.get("bits").?.object, arm_width); + } } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index c2814ae98..d19860697 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -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. diff --git a/src/terminal/point.zig b/src/terminal/point.zig index f6bf25c39..5937576cf 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -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; diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index e9b9354c3..3fecd4469 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -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; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index afbfa719c..105cd1e4f 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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;