libghostty: add ABI manifest schema

The ABI manifest previously had no machine-readable grammar or test that
the public export conformed to it.

Define a Draft 2020-12 schema and add a build check that executes
ghostty_type_json for native and wasm libraries before validation. Run
both forms in CI and publish the schema with the generated API docs.
This commit is contained in:
Mitchell Hashimoto
2026-08-15 20:21:59 -07:00
parent 9673a22b01
commit c75559589e
9 changed files with 491 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -342,6 +342,9 @@ typedef struct {
* this manifest rather than hardcoding them. Consumers should reject unknown
* schema versions and verify the descriptors they require at initialization.
*
* The formal format is defined by the
* <a href="types.schema.json">libghostty-vt ABI manifest JSON Schema</a>.
*
* Example (abbreviated):
* @code{.json}
* {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
{
"$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|i8|i16|i32|i64|opaque|u8|u16|u32|u64|void)$"
},
"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"
}
},
"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
},
"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/aliasDescriptor"
},
{
"$ref": "#/$defs/opaqueDescriptor"
}
]
}
}
}

View File

@@ -2,7 +2,8 @@
//!
//! 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.
//! 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");