feat(vt-capi): port the libghostty-vt C API

Ports ZIG/src/lib_vt.zig and terminal/c/* as crates/ghostty-vt-capi
(cdylib + staticlib + rlib, soname libghostty-vt.so) implementing the
unchanged include/ghostty/vt.h + vt/*.h ABI: OSC parser, SGR parser,
key events and key encoding, paste safety, colors, result codes - all
38 native symbols, plus the ghostty_wasm_* helpers on wasm32.

Verification:
- tests/abi.rs generates a C probe from the headers, compiled with cc,
  that checks sizeof/alignof/offsetof of every struct/union, every
  enumerator and #define against Rust, and links every declared symbol;
  nm -D exports equal the declared functions.
- Ghostty's example/c-vt* programs compile unchanged against the Rust
  library (shared and static) and produce the expected output.
- The wasm32 build runs Ghostty's wasm-key-encode/wasm-sgr example
  scripts unmodified under node (upstream's wasm-sgr tag table is stale
  vs sgr.h; a regenerated copy checks full output).

Test Plan:
- cargo test -p ghostty-vt-capi (22 unit + 2 ABI + 5 C + 3 wasm tests)
This commit is contained in:
ddidderr committed 2026-10-12 05:16:41 +02:00
1 parent 5b9a0723a9
commit 75252a7651
35 files changed
+6911

No files matched your search

+658
View File
@@ -0,0 +1,658 @@
//! ABI verification: a C program generated from the unchanged headers and
//! compiled with `cc` prints `sizeof`/`alignof`/`offsetof` of every struct and
//! union, `sizeof` of every enum/typedef, the value of every enumerator and
//! `#define`d constant, and takes the address of every declared function (so
//! linking proves the symbol is exported). The values are compared against the
//! Rust definitions (`size_of`/`align_of`/`offset_of!`, enum discriminants).
#![cfg(unix)]
#![allow(clippy::unwrap_used)]
mod common;
use std::{
collections::BTreeMap,
fmt::Write as _,
mem::{align_of, offset_of, size_of},
process::Command,
};
use ghostty_input::{
Mods,
key::Key,
key_mods::{ModSide, Side},
};
use ghostty_vt::{color::Name, kitty::key::Flags as KittyFlags};
use ghostty_vt_capi::{
alloc::{Allocator, AllocatorVtable},
color::GhosttyColorRgb,
key_encode::KeyEncoderOption,
key_event::KeyAction,
osc::OscCommandData,
result::GhosttyResult,
sgr::{SgrAttribute, SgrAttributeTag, SgrAttributeValue, SgrUnknown},
};
/// `GHOSTTY_FOO_BAR` / `FooBar` / `foo_bar` -> `foobar`.
fn norm(s: &str) -> String {
s.chars()
.filter(|c| *c != '_')
.map(|c| c.to_ascii_lowercase())
.collect()
}
/// Parsed `typedef enum { ... } Name;` blocks: typedef name -> enumerators.
fn header_enums() -> BTreeMap<String, Vec<String>> {
let mut map = BTreeMap::new();
for h in common::headers() {
let text = common::strip_comments(&std::fs::read_to_string(h).unwrap());
let mut rest = text.as_str();
while let Some(start) = rest.find("typedef enum") {
let after = &rest[start..];
let open = after.find('{').unwrap();
let close = after.find('}').unwrap();
let name_end = after[close..].find(';').unwrap() + close;
let name = after[close + 1..name_end].trim().to_string();
let names = after[open + 1..close]
.split(',')
.map(|e| e.split('=').next().unwrap().trim().to_string())
.filter(|e| !e.is_empty())
.collect();
map.insert(name, names);
rest = &after[name_end..];
}
}
map
}
/// `#define GHOSTTY_X ...` constants that are not include guards.
fn header_defines() -> Vec<String> {
let mut v = Vec::new();
for h in common::headers() {
for line in std::fs::read_to_string(h).unwrap().lines() {
if let Some(rest) = line.strip_prefix("#define GHOSTTY_") {
let name = rest.split_whitespace().next().unwrap();
if !name.ends_with("_H") && rest.split_whitespace().nth(1).is_some() {
v.push(format!("GHOSTTY_{name}"));
}
}
}
}
v
}
/// Names and values of a Rust-side enum, keyed by normalized name.
fn vals<T: std::fmt::Debug + Copy>(items: &[T], f: impl Fn(T) -> i64) -> BTreeMap<String, i64> {
items
.iter()
.map(|i| (norm(&format!("{i:?}")), f(*i)))
.collect()
}
struct Probe {
c: String,
expected: BTreeMap<String, i64>,
}
impl Probe {
fn emit(&mut self, key: &str, c_expr: &str, expected: i64) {
let _ = writeln!(
self.c,
" printf(\"%s=%lld\\n\", \"{key}\", (long long)({c_expr}));"
);
self.expected.insert(key.to_string(), expected);
}
fn layout(&mut self, ty: &str, size: usize, align: usize, fields: &[(&str, usize)]) {
self.emit(
&format!("sizeof({ty})"),
&format!("sizeof({ty})"),
size as i64,
);
self.emit(
&format!("alignof({ty})"),
&format!("_Alignof({ty})"),
align as i64,
);
for (f, off) in fields {
self.emit(
&format!("offsetof({ty},{f})"),
&format!("offsetof({ty}, {f})"),
*off as i64,
);
}
}
/// Check every enumerator of `typedef` against `rust` (names after
/// `prefix`); the C compiler provides the values. `exact` also requires
/// that Rust has no extra members.
fn enumerators(
&mut self,
enums: &BTreeMap<String, Vec<String>>,
typedef: &str,
prefix: &str,
rust: &BTreeMap<String, i64>,
exact: bool,
) {
let names = enums
.get(typedef)
.unwrap_or_else(|| panic!("no enum {typedef}"));
// All GHOSTTY enums are `int` sized.
self.emit(
&format!("sizeof({typedef})"),
&format!("sizeof({typedef})"),
4,
);
for n in names {
let short = n
.strip_prefix(prefix)
.unwrap_or_else(|| panic!("{n} lacks {prefix}"));
let want = *rust
.get(&norm(short))
.unwrap_or_else(|| panic!("{typedef}: Rust has no member for {n}"));
self.emit(n, n, want);
}
if exact {
assert_eq!(names.len(), rust.len(), "{typedef}: member count differs");
}
}
}
fn build_probe() -> Probe {
let enums = header_enums();
let mut p = Probe {
c: String::new(),
expected: BTreeMap::new(),
};
// ---- structs and unions ----
p.layout(
"GhosttyColorRgb",
size_of::<GhosttyColorRgb>(),
align_of::<GhosttyColorRgb>(),
&[
("r", offset_of!(GhosttyColorRgb, r)),
("g", offset_of!(GhosttyColorRgb, g)),
("b", offset_of!(GhosttyColorRgb, b)),
],
);
p.layout(
"GhosttyAllocatorVtable",
size_of::<AllocatorVtable>(),
align_of::<AllocatorVtable>(),
&[
("alloc", offset_of!(AllocatorVtable, alloc)),
("resize", offset_of!(AllocatorVtable, resize)),
("remap", offset_of!(AllocatorVtable, remap)),
("free", offset_of!(AllocatorVtable, free)),
],
);
p.layout(
"GhosttyAllocator",
size_of::<Allocator>(),
align_of::<Allocator>(),
&[
("ctx", offset_of!(Allocator, ctx)),
("vtable", offset_of!(Allocator, vtable)),
],
);
p.layout(
"GhosttySgrUnknown",
size_of::<SgrUnknown>(),
align_of::<SgrUnknown>(),
&[
("full_ptr", offset_of!(SgrUnknown, full_ptr)),
("full_len", offset_of!(SgrUnknown, full_len)),
("partial_ptr", offset_of!(SgrUnknown, partial_ptr)),
("partial_len", offset_of!(SgrUnknown, partial_len)),
],
);
p.layout(
"GhosttySgrAttributeValue",
size_of::<SgrAttributeValue>(),
align_of::<SgrAttributeValue>(),
&[
("unknown", offset_of!(SgrAttributeValue, unknown)),
("underline", offset_of!(SgrAttributeValue, underline)),
(
"underline_color",
offset_of!(SgrAttributeValue, underline_color),
),
(
"underline_color_256",
offset_of!(SgrAttributeValue, underline_color_256),
),
(
"direct_color_fg",
offset_of!(SgrAttributeValue, direct_color_fg),
),
(
"direct_color_bg",
offset_of!(SgrAttributeValue, direct_color_bg),
),
("bg_8", offset_of!(SgrAttributeValue, bg_8)),
("fg_8", offset_of!(SgrAttributeValue, fg_8)),
("bright_bg_8", offset_of!(SgrAttributeValue, bright_bg_8)),
("bright_fg_8", offset_of!(SgrAttributeValue, bright_fg_8)),
("bg_256", offset_of!(SgrAttributeValue, bg_256)),
("fg_256", offset_of!(SgrAttributeValue, fg_256)),
("_padding", offset_of!(SgrAttributeValue, _padding)),
],
);
p.layout(
"GhosttySgrAttribute",
size_of::<SgrAttribute>(),
align_of::<SgrAttribute>(),
&[
("tag", offset_of!(SgrAttribute, tag)),
("value", offset_of!(SgrAttribute, value)),
],
);
// ---- scalar typedefs and opaque handles ----
for (ty, size) in [
("GhosttyMods", 2),
("GhosttyKittyKeyFlags", 1),
("GhosttyColorPaletteIndex", 1),
("GhosttyOscParser", size_of::<usize>()),
("GhosttyOscCommand", size_of::<usize>()),
("GhosttySgrParser", size_of::<usize>()),
("GhosttyKeyEvent", size_of::<usize>()),
("GhosttyKeyEncoder", size_of::<usize>()),
] {
p.emit(
&format!("sizeof({ty})"),
&format!("sizeof({ty})"),
size as i64,
);
}
// ---- enums ----
p.enumerators(
&enums,
"GhosttyResult",
"GHOSTTY_",
&vals(
&[
GhosttyResult::Success,
GhosttyResult::OutOfMemory,
GhosttyResult::InvalidValue,
],
|v| v as i64,
),
true,
);
// The Rust OSC parser knows two commands newer than the header.
let osc = {
use ghostty_vt::osc::Key::*;
vals(
&[
Invalid,
ChangeWindowTitle,
ChangeWindowIcon,
SemanticPrompt,
ClipboardContents,
ReportPwd,
MouseShape,
ColorOperation,
KittyColorProtocol,
ShowDesktopNotification,
HyperlinkStart,
HyperlinkEnd,
ConemuSleep,
ConemuShowMessageBox,
ConemuChangeTabTitle,
ConemuProgressReport,
ConemuWaitInput,
ConemuGuimacro,
ConemuRunProcess,
ConemuOutputEnvironmentVariable,
ConemuXtermEmulation,
ConemuComment,
KittyTextSizing,
KittyClipboardProtocol,
ContextSignal,
],
|v| v as i64,
)
};
p.enumerators(
&enums,
"GhosttyOscCommandType",
"GHOSTTY_OSC_COMMAND_",
&osc,
false,
);
p.enumerators(
&enums,
"GhosttyOscCommandData",
"GHOSTTY_OSC_DATA_",
&vals(
&[
OscCommandData::Invalid,
OscCommandData::ChangeWindowTitleStr,
],
|v| v as i64,
),
true,
);
p.enumerators(
&enums,
"GhosttySgrAttributeTag",
"GHOSTTY_SGR_ATTR_",
&sgr_tags(),
true,
);
p.enumerators(
&enums,
"GhosttySgrUnderline",
"GHOSTTY_SGR_UNDERLINE_",
&vals(
&[
ghostty_vt::sgr::Underline::None,
ghostty_vt::sgr::Underline::Single,
ghostty_vt::sgr::Underline::Double,
ghostty_vt::sgr::Underline::Curly,
ghostty_vt::sgr::Underline::Dotted,
ghostty_vt::sgr::Underline::Dashed,
],
|v| v as i64,
),
true,
);
p.enumerators(
&enums,
"GhosttyKeyAction",
"GHOSTTY_KEY_ACTION_",
&vals(
&[KeyAction::Release, KeyAction::Press, KeyAction::Repeat],
|v| v as i64,
),
true,
);
// ghostty_input::Action must agree with the header too.
assert_eq!(ghostty_input::Action::Release as i32, 0);
assert_eq!(ghostty_input::Action::Press as i32, 1);
assert_eq!(ghostty_input::Action::Repeat as i32, 2);
let keys: BTreeMap<String, i64> = Key::ALL
.iter()
.enumerate()
.map(|(i, k)| {
let n = k.name();
(norm(n.strip_prefix("key_").unwrap_or(n)), i as i64)
})
.collect();
p.enumerators(&enums, "GhosttyKey", "GHOSTTY_KEY_", &keys, true);
for (i, k) in Key::ALL.iter().enumerate() {
assert_eq!(*k as usize, i, "Key discriminants are dense");
}
p.enumerators(
&enums,
"GhosttyKeyEncoderOption",
"GHOSTTY_KEY_ENCODER_OPT_",
&vals(
&[
KeyEncoderOption::CursorKeyApplication,
KeyEncoderOption::KeypadKeyApplication,
KeyEncoderOption::IgnoreKeypadWithNumlock,
KeyEncoderOption::AltEscPrefix,
KeyEncoderOption::ModifyOtherKeysState2,
KeyEncoderOption::KittyFlags,
KeyEncoderOption::MacosOptionAsAlt,
],
|v| v as i64,
),
true,
);
p.enumerators(
&enums,
"GhosttyOptionAsAlt",
"GHOSTTY_OPTION_AS_ALT_",
&vals(
&[
ghostty_input::OptionAsAlt::False,
ghostty_input::OptionAsAlt::True,
ghostty_input::OptionAsAlt::Left,
ghostty_input::OptionAsAlt::Right,
],
|v| v as i64,
),
true,
);
// ---- #define constants ----
let right = |f: fn(&mut Side)| {
let mut s = Side::default();
f(&mut s);
Mods {
sides: s,
..Mods::default()
}
.int() as i64
};
let _ = ModSide::Right;
let mod_flag = |f: fn(&mut Mods)| {
let mut m = Mods::default();
f(&mut m);
m.int() as i64
};
let kitty = |f: fn(&mut KittyFlags)| {
let mut k = KittyFlags::DISABLED;
f(&mut k);
k.int() as i64
};
let named = |n: Name| i64::from(n.0);
let defines: BTreeMap<&str, i64> = BTreeMap::from([
("GHOSTTY_MODS_SHIFT", mod_flag(|m| m.shift = true)),
("GHOSTTY_MODS_CTRL", mod_flag(|m| m.ctrl = true)),
("GHOSTTY_MODS_ALT", mod_flag(|m| m.alt = true)),
("GHOSTTY_MODS_SUPER", mod_flag(|m| m.super_ = true)),
("GHOSTTY_MODS_CAPS_LOCK", mod_flag(|m| m.caps_lock = true)),
("GHOSTTY_MODS_NUM_LOCK", mod_flag(|m| m.num_lock = true)),
(
"GHOSTTY_MODS_SHIFT_SIDE",
right(|s| s.shift = ModSide::Right),
),
("GHOSTTY_MODS_CTRL_SIDE", right(|s| s.ctrl = ModSide::Right)),
("GHOSTTY_MODS_ALT_SIDE", right(|s| s.alt = ModSide::Right)),
(
"GHOSTTY_MODS_SUPER_SIDE",
right(|s| s.super_ = ModSide::Right),
),
("GHOSTTY_KITTY_KEY_DISABLED", kitty(|_| {})),
(
"GHOSTTY_KITTY_KEY_DISAMBIGUATE",
kitty(|k| k.disambiguate = true),
),
(
"GHOSTTY_KITTY_KEY_REPORT_EVENTS",
kitty(|k| k.report_events = true),
),
(
"GHOSTTY_KITTY_KEY_REPORT_ALTERNATES",
kitty(|k| k.report_alternates = true),
),
(
"GHOSTTY_KITTY_KEY_REPORT_ALL",
kitty(|k| k.report_all = true),
),
(
"GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED",
kitty(|k| k.report_associated = true),
),
("GHOSTTY_KITTY_KEY_ALL", KittyFlags::TRUE.int() as i64),
("GHOSTTY_COLOR_NAMED_BLACK", named(Name::BLACK)),
("GHOSTTY_COLOR_NAMED_RED", named(Name::RED)),
("GHOSTTY_COLOR_NAMED_GREEN", named(Name::GREEN)),
("GHOSTTY_COLOR_NAMED_YELLOW", named(Name::YELLOW)),
("GHOSTTY_COLOR_NAMED_BLUE", named(Name::BLUE)),
("GHOSTTY_COLOR_NAMED_MAGENTA", named(Name::MAGENTA)),
("GHOSTTY_COLOR_NAMED_CYAN", named(Name::CYAN)),
("GHOSTTY_COLOR_NAMED_WHITE", named(Name::WHITE)),
(
"GHOSTTY_COLOR_NAMED_BRIGHT_BLACK",
named(Name::BRIGHT_BLACK),
),
("GHOSTTY_COLOR_NAMED_BRIGHT_RED", named(Name::BRIGHT_RED)),
(
"GHOSTTY_COLOR_NAMED_BRIGHT_GREEN",
named(Name::BRIGHT_GREEN),
),
(
"GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW",
named(Name::BRIGHT_YELLOW),
),
("GHOSTTY_COLOR_NAMED_BRIGHT_BLUE", named(Name::BRIGHT_BLUE)),
(
"GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA",
named(Name::BRIGHT_MAGENTA),
),
("GHOSTTY_COLOR_NAMED_BRIGHT_CYAN", named(Name::BRIGHT_CYAN)),
(
"GHOSTTY_COLOR_NAMED_BRIGHT_WHITE",
named(Name::BRIGHT_WHITE),
),
]);
let declared = header_defines();
for d in &declared {
let want = defines
.get(d.as_str())
.unwrap_or_else(|| panic!("header #define {d} has no Rust expectation"));
p.emit(d, d, *want);
}
assert_eq!(
declared.len(),
defines.len(),
"stale expectation in the define table"
);
p
}
fn sgr_tags() -> BTreeMap<String, i64> {
use SgrAttributeTag::*;
vals(
&[
Unset,
Unknown,
Bold,
ResetBold,
Italic,
ResetItalic,
Faint,
Underline,
UnderlineColor,
UnderlineColor256,
ResetUnderlineColor,
Overline,
ResetOverline,
Blink,
ResetBlink,
Inverse,
ResetInverse,
Invisible,
ResetInvisible,
Strikethrough,
ResetStrikethrough,
DirectColorFg,
DirectColorBg,
Bg8,
Fg8,
ResetFg,
ResetBg,
BrightBg8,
BrightFg8,
Bg256,
Fg256,
],
|v| v as i64,
)
}
#[test]
fn abi_matches_headers() {
let lib = common::install_native_lib();
let scratch = common::scratch_dir("abi");
let mut probe = build_probe();
let fns: Vec<String> = common::declared_functions()
.into_iter()
.filter(|f| !f.starts_with("ghostty_wasm_"))
.collect();
assert!(fns.len() == 38, "expected the full API, got {fns:?}");
let mut src = String::from(
"#include <stdio.h>\n#include <stddef.h>\n#include <stdalign.h>\n#include <ghostty/vt.h>\n",
);
src.push_str("static void *const fns[] = {\n");
for f in &fns {
let _ = writeln!(src, " (void *){f},");
}
src.push_str("};\nint main(void) {\n if (sizeof(fns) == 0) return 2;\n");
src.push_str(&probe.c);
src.push_str(" return 0;\n}\n");
let c_file = scratch.join("probe.c");
std::fs::write(&c_file, src).unwrap();
let exe = scratch.join("probe");
common::compile_c(&lib, &[&c_file], &exe, &["-Wall", "-Werror", "-std=c11"]);
let out = Command::new(&exe).output().unwrap();
assert!(out.status.success());
let got: BTreeMap<String, i64> = String::from_utf8(out.stdout)
.unwrap()
.lines()
.map(|l| {
let (k, v) = l.rsplit_once('=').unwrap();
(k.to_string(), v.parse().unwrap())
})
.collect();
probe.expected.insert("_".into(), 0);
let mut bad = Vec::new();
for (k, want) in &probe.expected {
if k == "_" {
continue;
}
match got.get(k) {
Some(g) if g == want => {}
g => bad.push(format!("{k}: C={g:?} Rust={want}")),
}
}
assert!(bad.is_empty(), "ABI mismatches:\n{}", bad.join("\n"));
assert!(got.len() > 300, "suspiciously few probes: {}", got.len());
}
/// Every exported function in the shared object is declared in the headers
/// (no accidental extra `ghostty_*` symbols on native targets).
#[test]
fn exported_symbols_match_headers() {
let lib = common::install_native_lib();
let out = Command::new("nm")
.args(["-D", "--defined-only"])
.arg(lib.join("libghostty-vt.so"))
.output();
let Ok(out) = out else {
eprintln!("nm not available; skipping");
return;
};
let text = String::from_utf8(out.stdout).unwrap();
let exported: Vec<&str> = text
.lines()
.filter_map(|l| l.split_whitespace().last())
.filter(|s| s.starts_with("ghostty_"))
.collect();
let declared: Vec<String> = common::declared_functions()
.into_iter()
.filter(|f| !f.starts_with("ghostty_wasm_"))
.collect();
for f in &declared {
assert!(
exported.contains(&f.as_str()),
"{f} declared but not exported"
);
}
for e in &exported {
assert!(
declared.iter().any(|d| d == e),
"{e} exported but not declared"
);
}
}
@@ -0,0 +1,94 @@
//! Builds Ghostty's unchanged C examples (examples/c/*.c, from
//! ZIG/example/c-vt*) against the Rust `libghostty-vt` and checks their output.
#![cfg(unix)]
#![allow(clippy::unwrap_used)]
mod common;
use std::process::Command;
fn run_example(name: &str) -> String {
let lib = common::install_native_lib();
let scratch = common::scratch_dir("c_examples");
let src = common::manifest_dir()
.join("examples/c")
.join(format!("{name}.c"));
let exe = scratch.join(name);
common::compile_c(&lib, &[&src], &exe, &["-Wall"]);
let out = Command::new(&exe).output().expect("run example");
assert!(out.status.success(), "{name} exited with {:?}", out.status);
String::from_utf8(out.stdout).unwrap()
}
#[test]
fn c_vt_osc() {
assert_eq!(
run_example("c-vt"),
"Command type: 1\nExtracted title: hello\n"
);
}
#[test]
fn c_vt_key_encode() {
assert_eq!(
run_example("c-vt-key-encode"),
"Encoding event: left ctrl release with all Kitty flags enabled\n\
Required buffer size: 12 bytes\n\
Encoded 12 bytes\n\
Hex: 1b 5b 35 37 34 34 32 3b 35 3a 33 75 \n\
String: \\x1b[57442;5:3u\n"
);
}
#[test]
fn c_vt_paste() {
assert_eq!(
run_example("c-vt-paste"),
"'hello world' is safe to paste\n\
'rm -rf /\n' is UNSAFE - contains newline\n\
Data with escape sequence is UNSAFE\n\
Empty data is safe\n"
);
}
#[test]
fn c_vt_sgr() {
assert_eq!(
run_example("c-vt-sgr"),
"Parsing Kakoune SGR sequence:\n\
ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m\n\
\n\
Attribute 1: Underline style = curly\n\
Attribute 2: Foreground RGB = (51, 51, 51)\n\
Attribute 3: Background RGB = (170, 170, 170)\n\
Attribute 4: Underline color RGB = (255, 97, 136)\n\
\n\
Total attributes parsed: 4\n"
);
}
/// The static archive links too (what embedders of `libghostty-vt.a` use).
#[test]
fn c_vt_links_statically() {
let lib = common::install_native_lib();
let scratch = common::scratch_dir("c_examples_static");
let src = common::manifest_dir().join("examples/c/c-vt-paste.c");
let exe = scratch.join("paste-static");
let out = Command::new("cc")
.arg("-I")
.arg(common::include_dir())
.arg(&src)
.arg(lib.join("libghostty-vt.a"))
.args(["-lpthread", "-ldl", "-lm"])
.arg("-o")
.arg(&exe)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let run = Command::new(&exe).output().unwrap();
assert!(String::from_utf8_lossy(&run.stdout).contains("Empty data is safe"));
}
+170
View File
@@ -0,0 +1,170 @@
//! Shared helpers: build the library and install it under Ghostty's file
//! names (`libghostty-vt.{so,a}`) next to the Cargo artifacts.
#![allow(dead_code)]
use std::{
path::{Path, PathBuf},
process::Command,
sync::OnceLock,
};
pub fn manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
pub fn include_dir() -> PathBuf {
manifest_dir().join("include")
}
/// `target/<profile>`, derived from the test executable's location.
pub fn profile_dir() -> PathBuf {
let exe = std::env::current_exe().expect("current_exe");
exe.parent() // deps
.and_then(Path::parent) // <profile>
.expect("profile dir")
.to_path_buf()
}
fn cargo() -> Command {
Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
}
static INSTALLED: OnceLock<PathBuf> = OnceLock::new();
/// Build the cdylib/staticlib for the host and install them as
/// `libghostty-vt.so` / `libghostty-vt.a` in the profile dir. Returns that dir.
pub fn install_native_lib() -> PathBuf {
INSTALLED.get_or_init(install_native_lib_once).clone()
}
fn install_native_lib_once() -> PathBuf {
let dir = profile_dir();
let profile = dir.file_name().and_then(|s| s.to_str()).unwrap_or("debug");
let mut cmd = cargo();
cmd.args(["build", "-p", "ghostty-vt-capi"]);
if profile != "debug" {
cmd.args(["--profile", profile]);
}
let status = cmd.status().expect("run cargo build");
assert!(status.success(), "cargo build -p ghostty-vt-capi failed");
for (from, to) in [
("libghostty_vt_capi.so", "libghostty-vt.so"),
("libghostty_vt_capi.a", "libghostty-vt.a"),
("libghostty_vt_capi.dylib", "libghostty-vt.dylib"),
] {
let src = dir.join(from);
if src.exists() {
let dst = dir.join(to);
// Copy + rename so a concurrent reader never sees a partial file.
let tmp = dir.join(format!("{to}.{}.tmp", std::process::id()));
std::fs::copy(&src, &tmp).expect("install library");
std::fs::rename(&tmp, &dst).expect("install library");
}
}
dir
}
/// Compile `sources` (C files) with `cc` against the installed library.
pub fn compile_c(lib_dir: &Path, sources: &[&Path], out: &Path, extra_cflags: &[&str]) {
let mut cmd = Command::new("cc");
cmd.arg("-I")
.arg(include_dir())
.args(extra_cflags)
.args(sources)
.arg("-o")
.arg(out)
.arg("-L")
.arg(lib_dir)
.arg("-lghostty-vt")
.arg(format!("-Wl,-rpath,{}", lib_dir.display()));
let output = cmd.output().expect("run cc");
assert!(
output.status.success(),
"cc failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
pub fn scratch_dir(name: &str) -> PathBuf {
let d = profile_dir().join("vt-capi-tests").join(name);
std::fs::create_dir_all(&d).expect("scratch dir");
d
}
/// Header text with `/* */` and `//` comments removed.
pub fn strip_comments(src: &str) -> String {
let b = src.as_bytes();
let mut out = String::with_capacity(src.len());
let mut i = 0;
while i < b.len() {
if b[i..].starts_with(b"/*") {
i += 2;
while i < b.len() && !b[i..].starts_with(b"*/") {
i += 1;
}
i += 2;
} else if b[i..].starts_with(b"//") {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
} else {
let ch = src[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
}
out
}
/// All headers (relative to `include/`), in a stable order.
pub fn headers() -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "h") {
out.push(p);
}
}
}
let mut v = Vec::new();
walk(&include_dir(), &mut v);
v.sort();
v
}
/// `ghostty_*` function names declared in the headers (comments removed).
pub fn declared_functions() -> Vec<String> {
let mut names = Vec::new();
for h in headers() {
let text = strip_comments(&std::fs::read_to_string(h).unwrap());
let b = text.as_bytes();
let mut i = 0;
while let Some(pos) = text[i..].find("ghostty_") {
let start = i + pos;
let mut end = start;
while end < b.len()
&& (b[end].is_ascii_lowercase() || b[end].is_ascii_digit() || b[end] == b'_')
{
end += 1;
}
let mut j = end;
while j < b.len() && b[j].is_ascii_whitespace() {
j += 1;
}
if j < b.len()
&& b[j] == b'('
&& !text[..start].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
{
let n = text[start..end].to_string();
if !names.contains(&n) {
names.push(n);
}
}
i = end.max(start + 1);
}
}
names.sort();
names
}
+146
View File
@@ -0,0 +1,146 @@
// Runs Ghostty's unmodified wasm examples (examples/wasm/*.html) under node
// with a minimal DOM stub, against the Rust-built ghostty-vt wasm module.
//
// node run.mjs exports <wasm> <names-file>
// node run.mjs sgr <sgr.html> <wasm> <sgr.h> [fix-tags]
// node run.mjs key <key-encode.html> <wasm>
//
// Output is line-oriented for the Rust test to parse.
import fs from 'node:fs';
import vm from 'node:vm';
const [mode, ...args] = process.argv.slice(2);
function script(html) {
const m = html.match(/<script>([\s\S]*)<\/script>/);
if (!m) throw new Error('no <script> in html');
return m[1];
}
function el(extra = {}) {
const listeners = {};
return {
textContent: '', className: '', value: '', checked: false, disabled: true,
style: {}, listeners,
addEventListener(type, fn) { (listeners[type] ??= []).push(fn); },
focus() {},
...extra,
};
}
/** Load an example page into a fresh context; returns helpers. */
async function load(htmlPath, wasmPath, transform = (s) => s) {
const html = fs.readFileSync(htmlPath, 'utf8');
const elements = {};
// Initial textarea contents (the sgr example's default sequence).
const ta = html.match(/<textarea id="(\w+)"[^>]*>([^<]*)<\/textarea>/);
if (ta) elements[ta[1]] = el({ value: ta[2] });
const winListeners = {};
const document = {
getElementById(id) { return (elements[id] ??= el()); },
querySelector() { return elements.__action ??= el({ value: '1', parentElement: { textContent: 'Press' } }); },
querySelectorAll(sel) {
if (sel.includes('checkbox')) return Object.keys(elements).filter((k) => k.startsWith('flag_')).map((k) => elements[k]);
return [];
},
};
const window = {
location: { protocol: 'http:' },
addEventListener(type, fn) { (winListeners[type] ??= []).push(fn); },
};
const wasmBytes = fs.readFileSync(wasmPath);
const fetch = async () => ({ arrayBuffer: async () => wasmBytes.buffer.slice(wasmBytes.byteOffset, wasmBytes.byteOffset + wasmBytes.byteLength) });
const ctx = vm.createContext({
document, window, fetch, console, TextEncoder, TextDecoder, DataView, Uint8Array, Uint16Array,
WebAssembly, Array, Object, parseInt, isNaN, Error, String,
});
vm.runInContext(transform(script(html)), ctx, { filename: htmlPath });
return { ctx, elements, winListeners, document };
}
async function runInit(page) {
for (const fn of page.winListeners.DOMContentLoaded ?? []) await fn();
}
function sgrTagsFromHeader(headerPath) {
const h = fs.readFileSync(headerPath, 'utf8');
const out = [];
for (const m of h.matchAll(/GHOSTTY_SGR_ATTR_([A-Z0-9_]+)\s*=\s*(\d+)/g)) out.push(`${m[1]}: ${m[2]}`);
return `const SGR_ATTR_TAGS = {\n${out.join(',\n')}\n};`;
}
if (mode === 'exports') {
const [wasmPath, namesFile] = args;
const mod = new WebAssembly.Module(fs.readFileSync(wasmPath));
const exported = new Set(WebAssembly.Module.exports(mod).map((e) => e.name));
const imports = WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}`);
console.log(`imports: ${imports.join(',')}`);
console.log(`has_memory: ${exported.has('memory')}`);
for (const n of fs.readFileSync(namesFile, 'utf8').split('\n').filter(Boolean)) {
if (!exported.has(n)) console.log(`MISSING ${n}`);
}
console.log('done');
} else if (mode === 'sgr') {
const [html, wasm, header, fix] = args;
const transform = fix === 'fix-tags'
? (s) => s.replace(/const SGR_ATTR_TAGS = \{[\s\S]*?\};/, sgrTagsFromHeader(header))
: (s) => s;
const page = await load(html, wasm, transform);
await runInit(page);
const out = page.elements.output;
const seq = page.elements.sequence;
console.log('=== default');
console.log(out.textContent);
for (const s of ['1;3;31;42', '38:5:123;48;5;200', '0', '4:0;4:1;4:2;4:3;4:4;4:5', '9999', '91;102;53']) {
seq.value = s;
for (const fn of seq.listeners.input ?? []) fn();
console.log(`=== ${s}`);
console.log(out.textContent);
}
} else if (mode === 'key') {
const [html, wasm] = args;
const page = await load(html, wasm);
await runInit(page);
const out = page.elements.output;
const input = page.elements.keyInput;
const press = (name, ev) => {
const e = { key: 'a', code: 'KeyA', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, preventDefault() {}, ...ev };
for (const fn of input.listeners.keydown ?? []) fn(e);
const m = out.textContent.match(/^Hex: (.*)$/m);
console.log(`${name}\t${m ? m[1].trim() : 'NONE'}`);
};
const setAction = (v, label) => {
const r = page.document.querySelector();
r.value = String(v); r.parentElement.textContent = label;
};
const setFlags = (on) => {
for (const id of Object.keys(page.elements).filter((k) => k.startsWith('flag_'))) page.elements[id].checked = on;
for (const id of Object.keys(page.elements).filter((k) => k.startsWith('flag_'))) {
for (const fn of page.elements[id].listeners.change ?? []) fn();
}
};
// The page creates the checkbox elements lazily via getElementById, which
// happens in init(); make sure they start checked (as in the HTML).
setFlags(true);
console.log('flags: all');
press('ctrl+a', { ctrlKey: true });
press('a', {});
press('shift+Enter', { key: 'Enter', code: 'Enter', shiftKey: true });
press('ArrowUp', { key: 'ArrowUp', code: 'ArrowUp' });
press('alt+x', { key: 'x', code: 'KeyX', altKey: true });
setAction(0, 'Release');
press('release a', {});
setAction(1, 'Press');
setFlags(false);
console.log('flags: none');
press('ctrl+a', { ctrlKey: true });
press('a', {});
press('shift+Enter', { key: 'Enter', code: 'Enter', shiftKey: true });
press('ArrowUp', { key: 'ArrowUp', code: 'ArrowUp' });
press('alt+x', { key: 'x', code: 'KeyX', altKey: true });
press('Escape', { key: 'Escape', code: 'Escape' });
press('F5', { key: 'F5', code: 'F5' });
press('unidentified', { key: 'Dead', code: 'Foo' });
} else {
throw new Error(`unknown mode ${mode}`);
}
@@ -0,0 +1,377 @@
//! Builds the crate for `wasm32-unknown-unknown` and runs Ghostty's wasm
//! examples (examples/wasm/*.html, unmodified) under node, checking that the
//! exported symbols match the headers and the examples produce the right
//! output.
#![cfg(unix)]
#![allow(clippy::unwrap_used)]
mod common;
use std::{path::PathBuf, process::Command, sync::OnceLock};
fn tool_available(cmd: &str, arg: &str) -> bool {
Command::new(cmd)
.arg(arg)
.output()
.is_ok_and(|o| o.status.success())
}
fn wasm_target_installed() -> bool {
Command::new("rustc")
.args([
"--print",
"target-libdir",
"--target",
"wasm32-unknown-unknown",
])
.output()
.ok()
.map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()))
.is_some_and(|p| {
p.join("libstd.rlib").exists()
|| std::fs::read_dir(p).is_ok_and(|mut d| {
d.any(|e| {
e.is_ok_and(|e| e.file_name().to_string_lossy().starts_with("libstd-"))
})
})
})
}
/// Path of the built wasm module, or `None` (with a note) if the toolchain
/// pieces are missing.
fn wasm_module() -> Option<PathBuf> {
static WASM: OnceLock<Option<PathBuf>> = OnceLock::new();
WASM.get_or_init(|| {
if !tool_available("node", "--version") {
eprintln!("node not installed; skipping wasm tests");
return None;
}
if !wasm_target_installed() {
eprintln!("wasm32-unknown-unknown target not installed; skipping wasm tests");
return None;
}
let profile = common::profile_dir();
let profile = profile
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("debug")
.to_string();
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
cmd.args([
"build",
"-p",
"ghostty-vt-capi",
"--target",
"wasm32-unknown-unknown",
]);
if profile != "debug" {
cmd.args(["--profile", &profile]);
}
assert!(cmd.status().unwrap().success(), "wasm build failed");
let target = common::profile_dir()
.parent()
.unwrap()
.join("wasm32-unknown-unknown");
let wasm = target.join(&profile).join("ghostty_vt_capi.wasm");
assert!(wasm.exists(), "{} missing", wasm.display());
// Install under the upstream name too (`ghostty-vt.wasm`).
let named = wasm.with_file_name("ghostty-vt.wasm");
std::fs::copy(&wasm, &named).unwrap();
Some(named)
})
.clone()
}
fn node(args: &[&str]) -> String {
let script = common::manifest_dir().join("tests/wasm/run.mjs");
let out = Command::new("node")
.arg(&script)
.args(args)
.output()
.expect("run node");
assert!(
out.status.success(),
"node failed:\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}
fn example(name: &str) -> String {
common::manifest_dir()
.join("examples/wasm")
.join(name)
.display()
.to_string()
}
#[test]
fn wasm_exports_match_headers() {
let Some(wasm) = wasm_module() else { return };
// Every function in every header, including the `__wasm__`-only ones.
let names = common::declared_functions().join("\n");
assert!(
names.contains("ghostty_wasm_alloc_sgr_attribute")
&& names.contains("ghostty_wasm_alloc_u8_array")
);
let names_file = common::scratch_dir("wasm").join("names.txt");
std::fs::write(&names_file, names).unwrap();
let out = node(&[
"exports",
&wasm.display().to_string(),
&names_file.display().to_string(),
]);
assert!(!out.contains("MISSING"), "{out}");
assert!(out.contains("has_memory: true"), "{out}");
// No host imports are needed (upstream imports `env.log`; ours logs nowhere).
assert!(out.contains("imports: \n"), "{out}");
}
fn sections(out: &str) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = Vec::new();
for line in out.lines() {
if let Some(name) = line.strip_prefix("=== ") {
v.push((name.to_string(), String::new()));
} else if let Some((_, body)) = v.last_mut() {
body.push_str(line);
body.push('\n');
}
}
v
}
#[test]
fn wasm_sgr_example() {
let Some(wasm) = wasm_module() else { return };
let html = example("sgr.html");
let wasm = wasm.display().to_string();
let header = common::include_dir()
.join("ghostty/vt/sgr.h")
.display()
.to_string();
// As shipped upstream, the page's tag table is stale relative to sgr.h
// (upstream commit a384af5e2 changed the header only), so only the
// attributes whose tags still agree (underline = 7) are labelled right.
let stock = node(&["sgr", &html, &wasm, &header]);
let (_, text) = &sections(&stock)[0];
assert!(
text.contains("Attribute 1: Underline style = curly"),
"{text}"
);
assert!(text.contains("Total attributes parsed: 4"), "{text}");
// With the tag table regenerated from sgr.h the page output is exact.
let out = node(&["sgr", &html, &wasm, &header, "fix-tags"]);
let s = sections(&out);
let get = |n: &str| {
s.iter()
.find(|(k, _)| k == n)
.unwrap_or_else(|| panic!("{n}"))
.1
.clone()
};
let head = "Parsing SGR sequence:\n";
assert_eq!(
get("default"),
format!(
"{head}ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m\n\n\
Attribute 1: Underline style = curly\n\
Attribute 2: Foreground RGB = (51, 51, 51)\n\
Attribute 3: Background RGB = (170, 170, 170)\n\
Attribute 4: Underline color RGB = (255, 97, 136)\n\
\nTotal attributes parsed: 4\n"
)
);
assert_eq!(
get("1;3;31;42"),
format!(
"{head}ESC[1;3;31;42m\n\n\
Attribute 1: Bold\n\
Attribute 2: Italic\n\
Attribute 3: Foreground 8-color = 1\n\
Attribute 4: Background 8-color = 2\n\
\nTotal attributes parsed: 4\n"
)
);
assert_eq!(
get("38:5:123;48;5;200"),
format!(
"{head}ESC[38:5:123;48;5;200m\n\n\
Attribute 1: Foreground 256-color = 123\n\
Attribute 2: Background 256-color = 200\n\
\nTotal attributes parsed: 2\n"
)
);
assert_eq!(
get("0"),
format!(
"{head}ESC[0m\n\nAttribute 1: Reset all attributes\n\nTotal attributes parsed: 1\n"
)
);
assert_eq!(
get("9999"),
format!(
"{head}ESC[9999m\n\nAttribute 1: Unknown attribute\n\nTotal attributes parsed: 1\n"
)
);
let underlines = get("4:0;4:1;4:2;4:3;4:4;4:5");
for style in ["none", "single", "double", "curly", "dotted", "dashed"] {
assert!(
underlines.contains(&format!("Underline style = {style}\n")),
"{underlines}"
);
}
let bright = get("91;102;53");
assert!(
bright.contains("Other attribute (tag=BRIGHT_FG_8)"),
"{bright}"
);
assert!(
bright.contains("Other attribute (tag=BRIGHT_BG_8)"),
"{bright}"
);
assert!(
bright.contains("Other attribute (tag=OVERLINE)"),
"{bright}"
);
}
fn hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn wasm_key_encode_example() {
use ghostty_input::{
Action,
Key,
KeyEvent,
Mods,
key_encode::{Options, encode},
};
use ghostty_vt::kitty::key::Flags;
let Some(wasm) = wasm_module() else { return };
let out = node(&[
"key",
&example("key-encode.html"),
&wasm.display().to_string(),
]);
let idx = out.lines().position(|l| l == "flags: none").unwrap();
let (with_flags, without): (Vec<_>, Vec<_>) = {
let lines: Vec<&str> = out.lines().collect();
(lines[..idx].to_vec(), lines[idx..].to_vec())
};
let parse = |lines: &[&str]| -> Vec<(String, String)> {
lines
.iter()
.filter_map(|l| l.split_once('\t'))
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
};
let (with_flags, without) = (parse(&with_flags), parse(&without));
let enc = |opts: Options, action: Action, key: Key, mods: Mods, utf8: &str, unshifted: char| {
let ev = KeyEvent {
action,
key,
mods,
utf8,
unshifted_codepoint: unshifted as u32,
..KeyEvent::default()
};
let mut buf = Vec::new();
encode(&mut buf, &ev, &opts).unwrap();
hex(&buf)
};
let ctrl = Mods {
ctrl: true,
..Mods::default()
};
let shift = Mods {
shift: true,
..Mods::default()
};
let alt = Mods {
alt: true,
..Mods::default()
};
let none = Mods::default();
let all = Options {
kitty_flags: Flags::TRUE,
..Options::default()
};
let legacy = Options::default();
let want_all = [
("ctrl+a", enc(all, Action::Press, Key::KeyA, ctrl, "a", 'a')),
("a", enc(all, Action::Press, Key::KeyA, none, "a", 'a')),
(
"shift+Enter",
enc(all, Action::Press, Key::Enter, shift, "", '\0'),
),
(
"ArrowUp",
enc(all, Action::Press, Key::ArrowUp, none, "", '\0'),
),
("alt+x", enc(all, Action::Press, Key::KeyX, alt, "x", 'x')),
(
"release a",
enc(all, Action::Release, Key::KeyA, none, "a", 'a'),
),
];
assert_eq!(with_flags.len(), want_all.len(), "{with_flags:?}");
for ((name, got), (wname, want)) in with_flags.iter().zip(want_all) {
assert_eq!(name, wname);
assert_eq!(*got, want, "kitty-all: {name}");
}
// Spot-check against the protocol, so a shared bug can't hide.
assert_eq!(with_flags[0].1, hex(b"\x1b[97;5u"));
assert_eq!(with_flags[2].1, hex(b"\x1b[13;2u"));
assert_eq!(with_flags[5].1, hex(b"\x1b[97;1:3u"));
let want_none = [
(
"ctrl+a",
enc(legacy, Action::Press, Key::KeyA, ctrl, "a", 'a'),
),
("a", enc(legacy, Action::Press, Key::KeyA, none, "a", 'a')),
(
"shift+Enter",
enc(legacy, Action::Press, Key::Enter, shift, "", '\0'),
),
(
"ArrowUp",
enc(legacy, Action::Press, Key::ArrowUp, none, "", '\0'),
),
(
"alt+x",
enc(legacy, Action::Press, Key::KeyX, alt, "x", 'x'),
),
(
"Escape",
enc(legacy, Action::Press, Key::Escape, none, "", '\0'),
),
("F5", enc(legacy, Action::Press, Key::F5, none, "", '\0')),
(
"unidentified",
enc(legacy, Action::Press, Key::Unidentified, none, "", '\0'),
),
];
assert_eq!(without.len(), want_none.len(), "{without:?}");
for ((name, got), (wname, want)) in without.iter().zip(want_none) {
assert_eq!(name, wname);
assert_eq!(*got, want, "legacy: {name}");
}
assert_eq!(without[0].1, "01");
assert_eq!(without[1].1, "61");
assert_eq!(without[3].1, hex(b"\x1b[A"));
// alt_esc_prefix is off by default in the C encoder, so alt+x is plain "x".
assert_eq!(without[4].1, hex(b"x"));
}