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,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
|
||||
}
|
||||
Reference in new issue
Block a user