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:
1 parent
5b9a0723a9
commit
75252a7651
35 files changed
+6911
No files matched your search
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) = §ions(&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"));
|
||||
}
|
||||
Reference in new issue
Block a user