mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 08:01:45 +00:00
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:
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
3
Doxyfile
3
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
|
||||
|
||||
10
build.zig
10
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,
|
||||
|
||||
@@ -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}
|
||||
* {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
114
src/terminal/c/types-schema-verify.py
Normal file
114
src/terminal/c/types-schema-verify.py
Normal 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()
|
||||
348
src/terminal/c/types.schema.json
Normal file
348
src/terminal/c/types.schema.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user