test: differential fuzzing of the terminal core against Ghostty's Zig code

Adds tools/difftest: a Zig driver built against a copy of Ghostty
1.3.1's own src/terminal (Zig 0.15.2, set up under target/ by
tools/difftest/setup.sh) and a Rust twin on ghostty-terminal, both
printing a canonical dump of every scrollback/active row (cells,
graphemes, styles, hyperlinks, row flags), cursor, modes, regions,
tabstops and colors. A seeded generator produces escape-heavy streams
(UTF-8/wide/combining/emoji, C0, CSI with edge params, SGR, modes, ESC,
OSC, DCS, margins, insert/delete); `just difftest` runs it with Zig
assertions on (ReleaseSafe vs debug Rust) and off (ReleaseFast vs
production Rust), with minimizers and a triage script.

Divergences found and fixed in Rust (each with a regression test):
- ill-formed UTF-8 decoding follows the SIMD path a default Ghostty
  build uses (not Zig's scalar fallback)
- PageList::erase_rows keeps Zig's row-flag behavior on regrow
- grapheme move on wrap with left/right margins uses the original cell

Upstream bugs found and deliberately NOT replicated (per the project
rule): a stale cell pointer after page growth in Terminal.zig:482/539
that drops a grapheme, plus several Zig assertion failures/segfaults;
documented with minimized repros in docs/rewrite/notes/difftest-DF.md.

Final run: 390k cases across many geometries, 0 divergences (Zig-abort
cases, ~0.1-0.5%, are not compared).

Test Plan:
- just difftest 20000 "1 2 3"
- cargo test -p ghostty-terminal (regression tests)
This commit is contained in:
ddidderr committed 2026-10-12 13:24:12 +02:00
1 parent 580ed4d52d
commit d13dcc8a7f
19 files changed
+2033 -165

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
[package]
name = "ghostty-difftest"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
ghostty-terminal.workspace = true
ghostty-vt.workspace = true
ghostty-unicode.workspace = true
[lints]
workspace = true
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Prepare the Zig side of the differential fuzzer (idempotent).
#
# 1. Zig 0.15.2 (Ghostty 1.3.1's required version) -> target/zig-0.15.2/
# 2. A COPY of the reference Ghostty source -> target/zig-src/
# (the reference tree itself is never modified)
# 3. The Zig driver, built twice:
# target/difftest-zig/bin/difftest_zig ReleaseSafe (assertions on)
# target/difftest-zig-fast/bin/difftest_zig ReleaseFast (assertions off)
#
# Everything lives under target/ (never /tmp, which is RAM-backed).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REF="${GHOSTTY_ZIG_REF:-$ROOT/../ghostty}"
ZIGVER=0.15.2
ZIGDIR="$ROOT/target/zig-$ZIGVER"
ZIG="$ZIGDIR/zig-x86_64-linux-$ZIGVER/zig"
if [ ! -x "$ZIG" ]; then
mkdir -p "$ZIGDIR"
timeout 900 curl -sSfL -o "$ZIGDIR/zig.tar.xz" \
"https://ziglang.org/download/$ZIGVER/zig-x86_64-linux-$ZIGVER.tar.xz"
timeout 300 tar -C "$ZIGDIR" -xf "$ZIGDIR/zig.tar.xz"
rm -f "$ZIGDIR/zig.tar.xz"
fi
if [ ! -f "$ROOT/target/zig-src/build.zig" ]; then
mkdir -p "$ROOT/target/zig-src"
timeout 600 rsync -a --exclude .git --exclude .zig-cache --exclude zig-cache \
"$REF/" "$ROOT/target/zig-src/"
fi
cd "$ROOT/tools/difftest/zig"
for m in ReleaseSafe:difftest-zig ReleaseFast:difftest-zig-fast; do
timeout 1800 "$ZIG" build -Doptimize="${m%%:*}" \
--cache-dir "$ROOT/target/zig-cache/local" \
--global-cache-dir "$ROOT/target/zig-cache/global" \
--prefix "$ROOT/target/${m##*:}"
done
+307
View File
@@ -0,0 +1,307 @@
//! Differential fuzz harness: Rust dump vs Zig driver output.
//!
//! ```text
//! difftest_fuzz run [--seed N] [--cases N] [--jobs N] [--tokens N]
//! [--zig PATH] [--work DIR] [--size CxR] [--sb BYTES]
//! difftest_fuzz minz|minr <cols> <rows> <sb> <input-file> # minimise an abort
//! difftest_fuzz min <cols> <rows> <sb> <input-file> # minimise a divergence
//! difftest_fuzz check <cols> <rows> <sb> <input-file> # compare one input
//! ```
use std::{
path::{Path, PathBuf},
process::Command,
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
},
};
use ghostty_difftest::{
dump,
inputgen::{Rng, config, generate},
};
struct Ctx {
zig: PathBuf,
work: PathBuf,
}
fn rust_dump(input: &[u8], c: u16, r: u16, sb: usize) -> String {
let input = input.to_vec();
std::panic::catch_unwind(move || dump(&input, c, r, sb))
.unwrap_or_else(|_| "RUST PANIC\n".to_string())
}
fn zig_dump(ctx: &Ctx, tag: &str, input: &[u8], c: u16, r: u16, sb: usize) -> String {
zig_run(ctx, tag, input, c, r, sb).0
}
/// Run the Zig driver. The bool is true when Zig grew a page's capacity
/// (`info(page_list): adjusting page capacity`) while running: several Zig
/// code paths keep raw cell pointers across such a reallocation (e.g. the
/// grapheme-attach after a wrap in `Terminal.print`), so a mismatch there can
/// be a use-after-free in Zig rather than a Rust bug.
fn zig_run(ctx: &Ctx, tag: &str, input: &[u8], c: u16, r: u16, sb: usize) -> (String, bool) {
let f = ctx.work.join(format!("in-{tag}.bin"));
std::fs::write(&f, input).expect("write input");
let out = Command::new(&ctx.zig)
.args([c.to_string(), r.to_string(), sb.to_string()])
.arg(&f)
.output()
.expect("spawn zig");
let _ = std::fs::remove_file(&f);
let grown = String::from_utf8_lossy(&out.stderr).contains("adjusting page capacity");
let text = if out.status.success() {
String::from_utf8_lossy(&out.stdout).into_owned()
} else {
format!(
"ZIG CRASH {:?}\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
)
};
(text, grown)
}
fn crashed(s: &str) -> bool {
s.starts_with("ZIG CRASH") || s.starts_with("RUST PANIC")
}
/// Two outputs are equivalent if identical, or if the Zig reference itself
/// aborted (a failed debug assertion or memory-safety fault in the Zig code
/// is undefined behaviour there, so there is no behaviour to compare; these
/// are counted and saved separately as `zigcrash`).
fn equivalent(rs: &str, z: &str) -> bool {
rs == z || z.starts_with("ZIG CRASH")
}
fn differs(ctx: &Ctx, tag: &str, input: &[u8], c: u16, r: u16, sb: usize) -> bool {
!equivalent(
&rust_dump(input, c, r, sb),
&zig_dump(ctx, tag, input, c, r, sb),
)
}
/// Predicate for `minc`: the Zig driver (or Rust, per `which`) aborts.
fn aborts(ctx: &Ctx, which: &str, input: &[u8], c: u16, r: u16, sb: usize) -> bool {
if which == "zig" {
crashed(&zig_dump(ctx, "minc", input, c, r, sb))
} else {
crashed(&rust_dump(input, c, r, sb))
}
}
fn minimize(ctx: &Ctx, input: &[u8], c: u16, r: u16, sb: usize, crash: Option<&str>) -> Vec<u8> {
let mut cur = input.to_vec();
let mut chunk = cur.len().div_ceil(2).max(1);
loop {
let mut i = 0;
let mut changed = false;
while i < cur.len() {
let end = (i + chunk).min(cur.len());
let mut cand = cur[..i].to_vec();
cand.extend_from_slice(&cur[end..]);
let keep = match crash {
Some(w) => aborts(ctx, w, &cand, c, r, sb),
None => differs(ctx, "min", &cand, c, r, sb),
};
if keep {
cur = cand;
changed = true;
} else {
i += chunk;
}
}
if chunk == 1 {
if !changed {
break;
}
} else {
chunk = chunk.div_ceil(2);
}
}
cur
}
fn show_diff(a: &str, b: &str) {
let (al, bl): (Vec<_>, Vec<_>) = (a.lines().collect(), b.lines().collect());
let mut shown = 0;
for i in 0..al.len().max(bl.len()) {
let (x, y) = (
al.get(i).copied().unwrap_or("<eof>"),
bl.get(i).copied().unwrap_or("<eof>"),
);
if x != y {
println!(" line {i}\n zig : {x}\n rust: {y}");
shown += 1;
if shown >= 4 {
break;
}
}
}
}
fn arg<T: std::str::FromStr>(a: &[String], name: &str, default: T) -> T {
a.iter()
.position(|x| x == name)
.and_then(|i| a.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let zig = a
.iter()
.position(|x| x == "--zig")
.and_then(|i| a.get(i + 1))
.map_or_else(
|| root.join("target/difftest-zig/bin/difftest_zig"),
PathBuf::from,
);
let work = root.join("target/difftest-work");
std::fs::create_dir_all(&work).expect("mkdir");
let ctx = Arc::new(Ctx { zig, work });
std::panic::set_hook(Box::new(|_| {}));
match a.first().map(String::as_str) {
Some("check" | "min" | "minz" | "minr") => {
let (c, r, sb): (u16, u16, usize) = (
a[1].parse().expect("numeric arg"),
a[2].parse().expect("numeric arg"),
a[3].parse().expect("numeric arg"),
);
let input = std::fs::read(&a[4]).expect("read");
let crash = match a[0].as_str() {
"minz" => Some("zig"),
"minr" => Some("rust"),
_ => None,
};
let input = if a[0] == "check" {
input
} else {
minimize(&ctx, &input, c, r, sb, crash)
};
if a[0] != "check" {
std::fs::write(ctx.work.join("min.bin"), &input).expect("write");
println!("minimised to {} bytes: {:02x?}", input.len(), input);
}
let (z, rs) = (
zig_dump(&ctx, "chk", &input, c, r, sb),
rust_dump(&input, c, r, sb),
);
if equivalent(&rs, &z) {
println!("SAME{}", if z == rs { "" } else { " (both abort)" });
} else {
show_diff(&z, &rs);
}
}
_ => run(&ctx, &a),
}
}
fn run(ctx: &Arc<Ctx>, a: &[String]) {
let seed: u64 = arg(a, "--seed", 1);
let cases: usize = arg(a, "--cases", 1000);
let jobs: usize = arg(a, "--jobs", 8);
let tokens: usize = arg(a, "--tokens", 60);
let size: String = arg(a, "--size", String::new());
let sb_fixed: Option<usize> = a
.iter()
.position(|x| x == "--sb")
.and_then(|i| a.get(i + 1)?.parse().ok());
let next = Arc::new(AtomicUsize::new(0));
let bad = Arc::new(Mutex::new(Vec::<usize>::new()));
let zig_only = Arc::new(AtomicUsize::new(0));
let suspects = Arc::new(AtomicUsize::new(0));
let zig_and_rust = Arc::new(AtomicUsize::new(0));
let fails_dir = ctx.work.join(format!("fails-{seed}"));
let handles: Vec<_> = (0..jobs)
.map(|j| {
let (ctx, next, bad, fails_dir, size) = (
ctx.clone(),
next.clone(),
bad.clone(),
fails_dir.clone(),
size.clone(),
);
let (zc, zboth, zsus) = (zig_only.clone(), zig_and_rust.clone(), suspects.clone());
std::thread::spawn(move || {
loop {
let i = next.fetch_add(1, Ordering::SeqCst);
if i >= cases {
break;
}
let mut rng = Rng(seed.wrapping_mul(0x1_0000_0001).wrapping_add(i as u64));
let (mut c, mut r, mut sb) = config(&mut rng);
if let Some((cc, rr)) = size.split_once('x') {
c = cc.parse().unwrap_or(c);
r = rr.parse().unwrap_or(r);
}
if let Some(s) = sb_fixed {
sb = s;
}
let n = 1 + rng.below(tokens);
let input = generate(&mut rng, n);
let rs = rust_dump(&input, c, r, sb);
let (z, grown) = zig_run(&ctx, &format!("w{j}"), &input, c, r, sb);
if z.starts_with("ZIG CRASH")
&& std::env::var_os("DIFFTEST_SAVE_ABORTS").is_some()
{
std::fs::create_dir_all(&fails_dir).expect("mkdir");
let stem = format!("abort{i}-{c}x{r}-sb{sb}");
std::fs::write(fails_dir.join(format!("{stem}.bin")), &input).expect("w");
std::fs::write(fails_dir.join(format!("{stem}.zig.txt")), &z).expect("w");
}
if z.starts_with("ZIG CRASH") && !rs.starts_with("RUST PANIC") {
zc.fetch_add(1, Ordering::SeqCst);
} else if z.starts_with("ZIG CRASH") {
zboth.fetch_add(1, Ordering::SeqCst);
}
if !equivalent(&rs, &z) {
std::fs::create_dir_all(&fails_dir).expect("mkdir");
let suspect = grown && !rs.starts_with("RUST PANIC");
let stem = format!(
"{}{i}-{c}x{r}-sb{sb}",
if suspect { "suspect" } else { "case" }
);
if suspect {
zsus.fetch_add(1, Ordering::SeqCst);
}
std::fs::write(fails_dir.join(format!("{stem}.bin")), &input).expect("w");
std::fs::write(fails_dir.join(format!("{stem}.zig.txt")), &z).expect("w");
std::fs::write(fails_dir.join(format!("{stem}.rs.txt")), &rs).expect("w");
if !suspect {
bad.lock().expect("lock").push(i);
}
}
}
})
})
.collect();
for h in handles {
h.join().expect("join");
}
let mut bad = bad.lock().expect("lock").clone();
bad.sort_unstable();
println!(
"seed={seed} cases={cases} divergences={} (zig aborted: {} rust-ok, {} rust-also-panicked; {} suspect: mismatch after Zig page growth, saved as suspect*)",
bad.len(),
zig_only.load(Ordering::SeqCst),
zig_and_rust.load(Ordering::SeqCst),
suspects.load(Ordering::SeqCst)
);
if !bad.is_empty() {
println!(
"failing cases saved in {} (first: {:?})",
fails_dir.display(),
&bad[..bad.len().min(10)]
);
std::process::exit(1);
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Rust twin of the Zig driver: `difftest_rs <cols> <rows> <max_scrollback> <file>`.
use std::io::Write as _;
fn main() {
let a: Vec<String> = std::env::args().collect();
assert!(
a.len() == 5,
"usage: difftest_rs cols rows max_scrollback file"
);
let input = std::fs::read(&a[4]).expect("read input");
let out = ghostty_difftest::dump(
&input,
a[1].parse().expect("cols"),
a[2].parse().expect("rows"),
a[3].parse().expect("scrollback"),
);
std::io::stdout().write_all(out.as_bytes()).expect("write");
}
+655
View File
@@ -0,0 +1,655 @@
//! Deterministic, escape-heavy random byte-stream generator.
/// splitmix64 PRNG (deterministic, dependency-free).
pub struct Rng(pub u64);
impl Rng {
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// Uniform in `0..n` (n > 0).
pub fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
pub fn chance(&mut self, num: usize, den: usize) -> bool {
self.below(den) < num
}
pub fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
&xs[self.below(xs.len())]
}
}
const WORDS: &[&str] = &[
"hello",
"world",
"The quick brown fox",
"x",
"ab",
"0123456789",
"~~~",
" ",
"foo bar baz",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
];
/// Interesting non-ASCII text: wide, combining, emoji, ZWJ, VS, regional
/// indicators, Hangul jamo, Indic, zero-width, ambiguous.
const UNICODE: &[&str] = &[
"é",
"e\u{301}",
"\u{301}",
"中",
"文字",
"ア",
"A",
"한",
"ᄀ\u{1161}\u{11a8}",
"😀",
"👍🏽",
"👨\u{200d}👩\u{200d}👧",
"🇺🇸",
"🇺",
"\u{fe0f}",
"\u{fe0e}",
"❤\u{fe0f}",
"❤\u{fe0e}",
"☺",
"\u{200b}",
"\u{200d}",
"\u{200c}",
"\u{feff}",
"\u{ad}",
"ä\u{308}\u{323}",
"क्षि",
"ก็",
"\u{1f3f4}\u{e0067}\u{e0062}\u{e007f}",
"®",
"©",
"™",
"\u{2764}",
"\u{1f600}\u{fe0f}",
"ß",
"─",
"│",
"█",
"\u{1b000}",
"\u{10ffff}",
"\u{e000}",
"\u{ffff}",
"\u{3000}",
"〜",
"\u{2060}",
"\u{0600}",
"\u{2028}",
"\u{85}",
];
const MODES_DEC: &[u32] = &[
1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 25, 40, 41, 45, 47, 66, 67, 69, 80, 95, 1000, 1002, 1003, 1004,
1005, 1006, 1007, 1015, 1016, 1035, 1036, 1039, 1045, 1047, 1048, 1049, 2004, 2026, 2027, 2031,
2048, 7727, 0, 9999, 65535, 12345,
];
const MODES_ANSI: &[u32] = &[2, 4, 12, 20, 0, 1, 3, 25, 99];
const CSI_FINALS: &[u8] = b"@ABCDEFGHIJKLMPSTXZ^`abcdefghijklmnpqrstuvwxyz{|}~";
const EDGE_NUMS: &[&str] = &[
"",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"24",
"25",
"80",
"100",
"255",
"256",
"999",
"1000",
"32767",
"32768",
"65534",
"65535",
"65536",
"65537",
"99999",
"2147483647",
"2147483648",
"4294967295",
"4294967296",
"18446744073709551616",
"-1",
];
fn num(r: &mut Rng, out: &mut Vec<u8>) {
if r.chance(3, 5) {
out.extend_from_slice(r.below(30).to_string().as_bytes());
} else {
out.extend_from_slice(r.pick(EDGE_NUMS).as_bytes());
}
}
fn params(r: &mut Rng, out: &mut Vec<u8>, max: usize) {
let n = r.below(max + 1);
for i in 0..n {
if i > 0 {
out.push(if r.chance(1, 12) { b':' } else { b';' });
}
num(r, out);
}
}
fn sgr(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b[");
let n = 1 + r.below(6);
for i in 0..n {
if i > 0 {
out.push(b';');
}
match r.below(14) {
0 => out.extend_from_slice(b"0"),
1 => out.extend_from_slice(
r.pick(&[
"1", "2", "3", "4", "5", "7", "8", "9", "21", "53", "22", "23", "24", "25",
"27", "28", "29", "55",
])
.as_bytes(),
),
2 => out.extend_from_slice(
r.pick(&[
"30", "31", "32", "37", "39", "40", "41", "47", "49", "90", "97", "100", "107",
"59",
])
.as_bytes(),
),
3 => {
out.extend_from_slice(b"38;2;");
for k in 0..3 {
if k > 0 {
out.push(b';');
}
num(r, out);
}
}
4 => {
out.extend_from_slice(r.pick(&["48", "58", "38"]).as_bytes());
out.extend_from_slice(b";5;");
num(r, out);
}
5 => {
out.extend_from_slice(
r.pick(&["4:0", "4:1", "4:2", "4:3", "4:4", "4:5", "4:9", "4:"])
.as_bytes(),
);
}
6 => {
out.extend_from_slice(b"38:2::");
for k in 0..3 {
if k > 0 {
out.push(b':');
}
num(r, out);
}
}
7 => {
out.extend_from_slice(b"48:2:");
for k in 0..4 {
if k > 0 {
out.push(b':');
}
num(r, out);
}
}
8 => out.extend_from_slice(b"58:5:"),
9 => num(r, out),
10 => out.extend_from_slice(
r.pick(&["38", "48", "58", "38;2", "38;5", "48;2;1", "58;2;1;2"])
.as_bytes(),
),
_ => out.extend_from_slice(r.below(110).to_string().as_bytes()),
}
}
out.push(b'm');
}
fn osc(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b]");
let colors = [
"rgb:ff/00/80",
"#ff0000",
"red",
"rgb:1/2/3",
"?",
"#abc",
"rgbi:0.5/0.5/0.5",
"#12345",
"blue;green",
"",
];
let c = |r: &mut Rng| *r.pick(&colors);
match r.below(18) {
0 => out.extend_from_slice(format!("0;title{}", r.below(100)).as_bytes()),
1 => out.extend_from_slice(format!("2;{}", c(r)).as_bytes()),
2 => out.extend_from_slice(format!("4;{};{}", r.below(260), c(r)).as_bytes()),
3 => out.extend_from_slice(
format!("4;{};{};{};{}", r.below(256), c(r), r.below(256), c(r)).as_bytes(),
),
4 => out.extend_from_slice(
format!(
"{};{}",
r.pick(&[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]),
c(r)
)
.as_bytes(),
),
5 => out.extend_from_slice(
r.pick(&["104", "110", "111", "112", "104;1;2", "104;300"])
.as_bytes(),
),
6 => out.extend_from_slice(format!("7;file://host/tmp/{}", r.below(10)).as_bytes()),
7 => out.extend_from_slice(
r.pick(&[
"8;;http://example.com",
"8;id=1;http://a.b",
"8;;",
"8;id=x:y=z;u",
])
.as_bytes(),
),
8 => out.extend_from_slice(
r.pick(&[
"133;A",
"133;B",
"133;C",
"133;D;0",
"133;A;k=s",
"133;L",
"133;P;k=c",
"133;N",
])
.as_bytes(),
),
9 => out.extend_from_slice(r.pick(&["52;c;aGVsbG8=", "52;c;?", "52;;"]).as_bytes()),
10 => out.extend_from_slice(
r.pick(&[
"9;4;1;50",
"9;hello",
"777;notify;a;b",
"1337;File=x",
"22;pointer",
"66;s=2;x",
"5522;x",
])
.as_bytes(),
),
11 => out.extend_from_slice(format!("{};", r.below(2000)).as_bytes()),
12 => out.extend_from_slice(
r.pick(&[
"21;foreground=?",
"21;foreground=red;background=#fff",
"21;palette=1=?",
])
.as_bytes(),
),
_ => {
let n = r.below(20);
for _ in 0..n {
out.push(32 + r.below(95) as u8);
}
}
}
match r.below(6) {
0..=2 => out.push(0x07),
3 | 4 => out.extend_from_slice(b"\x1b\\"),
_ => {} // unterminated
}
}
fn dcs(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1bP");
out.extend_from_slice(
r.pick(&[
"$qm",
"$q\"p",
"$qr",
"+q544e",
"1000p",
"1000p%begin 1 2 3\n",
"p",
"0;1|",
"1;2;3q",
"$q r",
"$t",
"=1s",
"garbage",
])
.as_bytes(),
);
let n = r.below(12);
for _ in 0..n {
out.push(32 + r.below(95) as u8);
}
if !r.chance(1, 8) {
out.extend_from_slice(b"\x1b\\");
}
}
fn apc(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b_");
out.extend_from_slice(
r.pick(&[
"Ga=T,f=24,s=1,v=1;AAAA",
"Ga=d",
"Gi=1,a=q;AAAA",
"Ga=p,i=1",
"Gq=2,a=T,f=100;",
"x",
"Ga=T,t=s,i=3;/x",
])
.as_bytes(),
);
out.extend_from_slice(b"\x1b\\");
}
fn csi(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b[");
match r.below(10) {
0 => out.push(b'?'),
1 => out.push(*r.pick(b">=<!")),
_ => {}
}
params(r, out, 5);
if r.chance(1, 10) {
out.push(*r.pick(b" $\"!#'*+%,"));
}
let f = *r.pick(CSI_FINALS);
out.push(f);
}
fn csi_common(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b[");
let finals = b"HABCDEFGdJKLMP@XSTIZbg`aef";
let f = *r.pick(finals);
let n = r.below(3);
for i in 0..n {
if i > 0 {
out.push(b';');
}
if r.chance(3, 4) {
out.extend_from_slice(r.below(12).to_string().as_bytes());
} else {
num(r, out);
}
}
out.push(f);
}
fn margins(r: &mut Rng, out: &mut Vec<u8>) {
let fin = if r.chance(1, 3) { b's' } else { b'r' };
if fin == b's' && r.chance(2, 3) {
out.extend_from_slice(b"\x1b[?69h");
}
out.extend_from_slice(b"\x1b[");
if r.chance(9, 10) {
out.extend_from_slice(r.below(10).to_string().as_bytes());
out.push(b';');
out.extend_from_slice(r.below(14).to_string().as_bytes());
}
out.push(fin);
}
fn mode(r: &mut Rng, out: &mut Vec<u8>) {
let set = r.chance(1, 2);
if r.chance(3, 4) {
out.extend_from_slice(b"\x1b[?");
let n = 1 + r.below(3);
for i in 0..n {
if i > 0 {
out.push(b';');
}
out.extend_from_slice(r.pick(MODES_DEC).to_string().as_bytes());
}
out.push(match r.below(8) {
0 => b's',
1 => b'r',
_ => {
if set {
b'h'
} else {
b'l'
}
}
});
} else {
out.extend_from_slice(b"\x1b[");
out.extend_from_slice(r.pick(MODES_ANSI).to_string().as_bytes());
out.push(if set { b'h' } else { b'l' });
}
}
fn esc(r: &mut Rng, out: &mut Vec<u8>) {
out.push(0x1b);
match r.below(12) {
0 => out.push(*r.pick(b"78DEMHc=>NOno|}~Z\\69VWFlm")),
1 => {
out.push(*r.pick(b"()*+-./"));
out.push(*r.pick(b"B0AUK<>12456789=%7HCEQRYZ`afi"));
}
2 => {
out.push(b'#');
out.push(*r.pick(b"3456789"));
}
3 => {
out.push(b'%');
out.push(*r.pick(b"G@8"));
}
4 => {
out.push(b' ');
out.push(*r.pick(b"FGLMN"));
}
_ => out.push(32 + r.below(95) as u8),
}
}
fn text(r: &mut Rng, out: &mut Vec<u8>) {
match r.below(12) {
0..=3 => out.extend_from_slice(r.pick(WORDS).as_bytes()),
4..=7 => out.extend_from_slice(r.pick(UNICODE).as_bytes()),
8 => {
let n = 1 + r.below(30);
let c = 33 + r.below(90) as u8;
out.extend(std::iter::repeat_n(c, n));
}
9 => {
// random unicode scalar
let cp = match r.below(5) {
0 => r.below(0x80) as u32,
1 => 0x80 + r.below(0x780) as u32,
2 => 0x800 + r.below(0xf000) as u32,
_ => 0x10000 + r.below(0x10000) as u32,
};
if let Some(ch) = char::from_u32(cp) {
let mut b = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut b).as_bytes());
}
}
10 => {
// invalid / truncated utf8 and C1 bytes
let n = 1 + r.below(3);
for _ in 0..n {
out.push(*r.pick(&[
0x80, 0x9b, 0x90, 0x9d, 0x9c, 0xc0, 0xc3, 0xe4, 0xe4, 0xb8, 0xf0, 0x9f, 0xf8,
0xfe, 0xff, 0xa9, 0x85, 0xed, 0xa0,
]));
}
}
_ => {
let n = 1 + r.below(8);
for _ in 0..n {
out.push(32 + r.below(95) as u8);
}
}
}
}
fn token(r: &mut Rng, out: &mut Vec<u8>) {
match r.below(100) {
0..=29 => text(r, out),
30..=39 => out.push(*r.pick(&[
b'\r', b'\n', b'\n', b'\t', b'\t', 0x08, 0x0b, 0x0c, 0x0e, 0x0f, 0x07, 0x00, 0x05,
0x7f, 0x1a, 0x18, 0x1f, 0x01, 0x11, 0x13,
])),
40..=47 => sgr(r, out),
48..=57 => csi_common(r, out),
58..=64 => csi(r, out),
65..=71 => mode(r, out),
72..=75 => margins(r, out),
76..=81 => esc(r, out),
82..=85 => osc(r, out),
86..=87 => dcs(r, out),
88 => apc(r, out),
89..=91 => {
// tabs stuff
out.extend_from_slice(
r.pick(&[
"\x1b[3g",
"\x1b[0g",
"\x1bH",
"\t\t",
"\x1b[2I",
"\x1b[3Z",
"\x1b[?5W",
"\x1b[2 W",
"\x1b[0;5;9 W",
])
.as_bytes(),
);
}
92..=94 => {
// fill screen to provoke scrolling
let n = 1 + r.below(40);
for _ in 0..n {
out.extend_from_slice(b"line text\r\n");
}
}
95 => out.extend_from_slice(
r.pick(&[
"\x1b[?1049h",
"\x1b[?1049l",
"\x1b[?47h",
"\x1b[?47l",
"\x1b[?1047h",
"\x1b[?1048h",
"\x1b[?1048l",
"\x1bc",
"\x1b[!p",
"\x1b[2J",
"\x1b[3J",
"\x1b[H\x1b[J",
"\x1b[?25l",
])
.as_bytes(),
),
96 => out.extend_from_slice(
r.pick(&[
"\x1b[1\"q",
"\x1b[2\"q",
"\x1b[0\"q",
"\x1bV",
"\x1bW",
"\x1b[?1J",
"\x1b[?2K",
"\x1b[1;5r\x1b[?6h",
"\x1b[0 q",
"\x1b[5 q",
"\x1b[>1u",
"\x1b[=5;1u",
"\x1b[<u",
"\x1b[?u",
"\x1b[>4;2m",
])
.as_bytes(),
),
97 if r.chance(1, 2) => {
// hyperlink span with text, sometimes left open
let uri = r.pick(&["http://a.example/x", "file:///tmp/y", "", "https://z"]);
let id = r.pick(&["", "id=1", "id=abc", "foo=bar:id=q"]);
out.extend_from_slice(format!("\x1b]8;{id};{uri}\x1b\\").as_bytes());
text(r, out);
if r.chance(3, 4) {
out.extend_from_slice(b"\x1b]8;;\x1b\\");
}
}
97 => out.extend_from_slice(
b"\x1b[?7h\x1b[?7l\x1b[?45h\x1b[?1045h\x1b[?4h\x1b[4l\x1b[?2027h\x1b[?2027l",
),
98 => {
// grapheme clustering + margins + right-edge cursor
out.extend_from_slice(b"\x1b[?2027h\x1b[?69h");
out.extend_from_slice(
format!("\x1b[{};{}s", 1 + r.below(5), 3 + r.below(12)).as_bytes(),
);
out.extend_from_slice(
format!("\x1b[{};{}H", 1 + r.below(30), 1 + r.below(200)).as_bytes(),
);
text(r, out);
text(r, out);
}
_ => {
// move cursor to a random edge before writing
out.extend_from_slice(format!("\x1b[{};{}H", r.below(60), r.below(120)).as_bytes());
}
}
}
/// Generate a byte stream of roughly `tokens` tokens.
pub fn generate(r: &mut Rng, tokens: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..tokens {
token(r, &mut out);
}
// Occasionally mutate raw bytes to hit odd parser paths.
if r.chance(1, 8) && !out.is_empty() {
for _ in 0..=r.below(4) {
let i = r.below(out.len());
out[i] = r.below(256) as u8;
}
}
out
}
/// Choose terminal geometry and scrollback for a case.
pub fn config(r: &mut Rng) -> (u16, u16, usize) {
let sizes: &[(u16, u16)] = &[
(1, 1),
(2, 1),
(1, 2),
(2, 2),
(3, 3),
(5, 3),
(8, 4),
(10, 5),
(16, 6),
(20, 8),
(40, 10),
(80, 24),
(132, 12),
(7, 30),
(255, 5),
(300, 3),
];
let (c, rr) = *r.pick(sizes);
let sb = *r.pick(&[0usize, 1, 100, 10_000, 10_000, 100_000]);
(c, rr, sb)
}
+294
View File
@@ -0,0 +1,294 @@
//! Differential-testing support: the canonical terminal dump (Rust twin of
//! `tools/difftest/zig/src/main.zig`) and the deterministic input generator.
//!
//! The dump format must stay byte-identical to the Zig driver's output.
pub mod inputgen;
use std::fmt::Write as _;
use ghostty_terminal::{
hyperlink::HyperlinkId,
point::{Coordinate, Point},
screen::Screen,
screen_set::Key,
style::{Color, Style},
terminal::{Options, Terminal},
};
use ghostty_vt::modes::ENTRIES;
fn color(w: &mut String, c: Color) {
match c {
Color::None => w.push('n'),
Color::Palette(p) => {
let _ = write!(w, "p{p}");
}
Color::Rgb(r) => {
let _ = write!(w, "r{:02x}{:02x}{:02x}", r.r, r.g, r.b);
}
}
}
fn style_str(w: &mut String, s: &Style) {
color(w, s.fg_color);
w.push(',');
color(w, s.bg_color);
w.push(',');
color(w, s.underline_color);
let f = &s.flags;
let bits = u16::from(f.bold)
| (u16::from(f.italic) << 1)
| (u16::from(f.faint) << 2)
| (u16::from(f.blink) << 3)
| (u16::from(f.inverse) << 4)
| (u16::from(f.invisible) << 5)
| (u16::from(f.strikethrough) << 6)
| (u16::from(f.overline) << 7)
| ((f.underline as u16) << 8);
let _ = write!(w, ",{:x}", bits & 0x7ff);
}
fn charset_str(w: &mut String, c: ghostty_terminal::screen::CharsetState) {
let _ = write!(
w,
"gl={} gr={} ss={} g={},{},{},{}",
c.gl as u8,
c.gr as u8,
c.single_shift.map_or(-1, |s| i32::from(s as u8)),
c.charsets.g0 as u8,
c.charsets.g1 as u8,
c.charsets.g2 as u8,
c.charsets.g3 as u8,
);
}
fn dump_screen(w: &mut String, name: &str, s: &Screen) {
let _ = writeln!(w, "[screen {name}]");
let c = &s.cursor;
let _ = write!(
w,
"cursor {} {} wrap={} prot={} cstyle={} style=",
c.x,
c.y,
u8::from(c.pending_wrap),
u8::from(c.protected),
c.cursor_style as u8
);
style_str(w, &c.style);
let _ = writeln!(w, " hl={}", u8::from(c.hyperlink_id != 0));
if let Some(sc) = &s.saved_cursor {
let _ = write!(
w,
"saved {} {} wrap={} prot={} origin={} style=",
sc.x,
sc.y,
u8::from(sc.pending_wrap),
u8::from(sc.protected),
u8::from(sc.origin)
);
style_str(w, &sc.style);
w.push(' ');
charset_str(w, sc.charset);
w.push('\n');
} else {
w.push_str("saved none\n");
}
w.push_str("charset ");
charset_str(w, s.charset);
w.push('\n');
let _ = writeln!(w, "protected_mode {}", s.protected_mode as u8);
let _ = writeln!(w, "kitty_kb {}", s.kitty_keyboard.current().int());
let pages = &s.pages;
let _ = writeln!(w, "rows {}", pages.total_rows);
for y in 0..pages.total_rows {
let pt = Point::Screen(Coordinate { x: 0, y: y as u32 });
let pin = pages.pin(pt).expect("pin");
let (row, _) = pin.row_and_cell(pages).expect("row");
let _ = write!(
w,
"row {y} w={} wc={} sp={} kv={} |",
u8::from(row.wrap()),
u8::from(row.wrap_continuation()),
row.semantic_prompt() as u8,
u8::from(row.kitty_virtual_placeholder()),
);
for x in 0..pages.cols {
let cpin = ghostty_terminal::page_list::Pin { x, ..pin };
let (_, cl) = cpin.row_and_cell(pages).expect("cell");
let st = cpin.style(pages).expect("style");
let tag = cl.content_tag() as u8;
let plain = tag == 0
&& cl.codepoint() == 0
&& cl.wide() as u8 == 0
&& !cl.protected()
&& !cl.hyperlink()
&& cl.semantic_content() as u8 == 0
&& st == Style::default();
if plain {
continue;
}
let _ = write!(w, " {x}:{tag}:");
match tag {
0 | 1 => {
let _ = write!(w, "{:x}", cl.codepoint());
}
2 => {
let _ = write!(w, "{}", cl.color_palette());
}
_ => {
let r = cl.color_rgb();
let _ = write!(w, "{:02x}{:02x}{:02x}", r.r, r.g, r.b);
}
}
if tag == 1 {
match cpin.grapheme(pages) {
Some(g) => {
for cp in g {
let _ = write!(w, "+{cp:x}");
}
}
None => w.push_str("+?"),
}
}
let _ = write!(
w,
":{}:{}{}{}:",
cl.wide() as u8,
if cl.protected() { "p" } else { "" },
if cl.hyperlink() { "h" } else { "" },
cl.semantic_content() as u8,
);
style_str(w, &st);
if cl.hyperlink() {
let page = &pages.node(cpin.node).expect("node").data;
let idx = page
.cell_index(usize::from(cpin.x), usize::from(cpin.y))
.expect("idx");
match page
.lookup_hyperlink(idx)
.and_then(|id| page.hyperlink_set.get(id))
{
Some(h) => {
let _ = write!(w, " @{}", String::from_utf8_lossy(&h.uri));
match &h.id {
HyperlinkId::Explicit(e) => {
let _ = write!(w, "@E:{}", String::from_utf8_lossy(e));
}
HyperlinkId::Implicit(n) => {
let _ = write!(w, "@I:{n}");
}
}
}
None => w.push_str(" @?"),
}
}
}
w.push('\n');
}
}
/// Feed `input` to a fresh terminal through the read-only stream and return
/// the canonical dump.
pub fn dump(input: &[u8], cols: u16, rows: u16, max_scrollback: usize) -> String {
let mut opts = Options::new(cols, rows);
opts.max_scrollback = max_scrollback;
let mut t = Terminal::new(opts).expect("terminal");
{
let mut s = t.vt_stream();
s.next_slice(input).expect("stream");
}
dump_terminal(&t)
}
/// Canonical dump of an already-driven terminal.
pub fn dump_terminal(t: &Terminal) -> String {
let mut w = String::new();
let _ = writeln!(w, "size {} {}", t.cols, t.rows);
let _ = writeln!(
w,
"active {}",
match t.screens.active_key {
Key::Primary => "primary",
Key::Alternate => "alternate",
}
);
let sr = &t.scrolling_region;
let _ = writeln!(
w,
"region t={} b={} l={} r={}",
sr.top, sr.bottom, sr.left, sr.right
);
w.push_str("modes");
for e in ENTRIES {
if t.modes.get(e.mode) {
let _ = write!(w, " {}", e.name);
}
}
w.push_str("\nsaved_modes");
for e in ENTRIES {
if t.modes.saved.get(e.mode) {
let _ = write!(w, " {}", e.name);
}
}
w.push_str("\ntabstops");
for x in 0..usize::from(t.cols) {
if t.tabstops.get(x) {
let _ = write!(w, " {x}");
}
}
let f = &t.flags;
let _ = writeln!(
w,
"\nflags srp={} mok2={} mev={} mfmt={} msc={}",
f.shell_redraws_prompt as u8,
u8::from(f.modify_other_keys_2),
f.mouse_event as u8,
f.mouse_format as u8,
f.mouse_shift_capture as u8
);
let _ = writeln!(w, "status_display {}", t.status_display as u8);
let _ = writeln!(w, "mouse_shape {}", t.mouse_shape as u8);
let _ = writeln!(w, "pwd {}", String::from_utf8_lossy(&t.pwd));
match t.previous_char {
Some(pc) => {
let _ = writeln!(w, "prev {pc:x}");
}
None => w.push_str("prev none\n"),
}
for (n, d) in [
("foreground", &t.colors.foreground),
("background", &t.colors.background),
("cursor", &t.colors.cursor),
] {
let _ = write!(w, "color {n}");
for (tag, v) in [("o", d.override_), ("d", d.default)] {
match v {
Some(o) => {
let _ = write!(w, " {tag}={:02x}{:02x}{:02x}", o.r, o.g, o.b);
}
None => {
let _ = write!(w, " {tag}=-");
}
}
}
w.push('\n');
}
w.push_str("palette");
for (i, c) in t.colors.palette.current.iter().enumerate() {
let o = &t.colors.palette.original[i];
if (c.r, c.g, c.b) != (o.r, o.g, o.b) {
let _ = write!(w, " {i}={:02x}{:02x}{:02x}", c.r, c.g, c.b);
}
}
w.push('\n');
if let Some(s) = t.screens.get(Key::Primary) {
dump_screen(&mut w, "primary", s);
}
if let Some(s) = t.screens.get(Key::Alternate) {
dump_screen(&mut w, "alternate", s);
}
w.push_str("[plain]\n");
w.push_str(&t.plain_string().expect("plain"));
w.push_str("\n[end]\n");
w
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Minimise every failing case in a fails-<seed> dir and print a compact report.
# usage: triage.sh <fails-dir> [max-cases]
set -u
D=$1; N=${2:-30}
BIN="$(dirname "$0")/../../target/debug/difftest_fuzz"
for f in $(ls "$D"/*.bin | head -n "$N"); do
n=$(basename "$f" .bin); IFS=- read -r _ sz sb <<<"$n"
echo "== $n"
timeout 300 "$BIN" min "${sz%x*}" "${sz#*x}" "${sb#sb}" "$f" | head -8
done
+17
View File
@@ -0,0 +1,17 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
if (b.lazyDependency("ghostty", .{ .simd = true, .optimize = optimize })) |dep| {
exe_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
}
const exe = b.addExecutable(.{ .name = "difftest_zig", .root_module = exe_mod });
b.installArtifact(exe);
}
+12
View File
@@ -0,0 +1,12 @@
.{
.name = .difftest_zig,
.version = "0.0.0",
.fingerprint = 0x5509a6937e82f017,
.minimum_zig_version = "0.15.1",
.dependencies = .{
// A COPY of the reference Ghostty source (never the reference tree
// itself), created by `just difftest-setup`.
.ghostty = .{ .path = "../../../target/zig-src" },
},
.paths = .{ "build.zig", "build.zig.zon", "src" },
}
+188
View File
@@ -0,0 +1,188 @@
//! Differential-testing driver (Zig side). Usage:
//! difftest_zig <cols> <rows> <max_scrollback> <input-file>
//! Feeds the file through the read-only stream and prints a canonical dump.
//! The Rust twin (tools/difftest/src/main.rs) must print byte-identical text.
const std = @import("std");
const vt = @import("ghostty-vt");
const Screen = vt.Screen;
fn color(w: *std.Io.Writer, c: vt.Style.Color) !void {
switch (c) {
.none => try w.writeAll("n"),
.palette => |p| try w.print("p{d}", .{p}),
.rgb => |r| try w.print("r{x:0>2}{x:0>2}{x:0>2}", .{ r.r, r.g, r.b }),
}
}
fn styleStr(w: *std.Io.Writer, s: vt.Style) !void {
try color(w, s.fg_color);
try w.writeAll(",");
try color(w, s.bg_color);
try w.writeAll(",");
try color(w, s.underline_color);
const bits: u16 = @bitCast(s.flags);
try w.print(",{x}", .{bits & 0x7ff});
}
fn charsetStr(w: *std.Io.Writer, c: Screen.CharsetState) !void {
try w.print("gl={d} gr={d} ss={d} g={d},{d},{d},{d}", .{
@intFromEnum(c.gl),
@intFromEnum(c.gr),
if (c.single_shift) |ss| @as(i32, @intFromEnum(ss)) else -1,
@intFromEnum(c.charsets.g0),
@intFromEnum(c.charsets.g1),
@intFromEnum(c.charsets.g2),
@intFromEnum(c.charsets.g3),
});
}
fn dumpScreen(w: *std.Io.Writer, name: []const u8, s: *Screen) !void {
try w.print("[screen {s}]\n", .{name});
const c = s.cursor;
try w.print("cursor {d} {d} wrap={d} prot={d} cstyle={d} style=", .{ c.x, c.y, @intFromBool(c.pending_wrap), @intFromBool(c.protected), @intFromEnum(c.cursor_style) });
try styleStr(w, c.style);
try w.print(" hl={d}\n", .{@intFromBool(c.hyperlink_id != 0)});
if (s.saved_cursor) |sc| {
try w.print("saved {d} {d} wrap={d} prot={d} origin={d} style=", .{ sc.x, sc.y, @intFromBool(sc.pending_wrap), @intFromBool(sc.protected), @intFromBool(sc.origin) });
try styleStr(w, sc.style);
try w.writeAll(" ");
try charsetStr(w, sc.charset);
try w.writeAll("\n");
} else try w.writeAll("saved none\n");
try w.writeAll("charset ");
try charsetStr(w, s.charset);
try w.writeAll("\n");
try w.print("protected_mode {d}\n", .{@intFromEnum(s.protected_mode)});
try w.print("kitty_kb {d}\n", .{@as(u5, @bitCast(s.kitty_keyboard.current()))});
try w.print("rows {d}\n", .{s.pages.total_rows});
var y: usize = 0;
while (y < s.pages.total_rows) : (y += 1) {
const cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = @intCast(y) } }).?;
const row = cell.row;
const pin = s.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(y) } }).?;
const cells = pin.cells(.all);
try w.print("row {d} w={d} wc={d} sp={d} kv={d} |", .{
y,
@intFromBool(row.wrap),
@intFromBool(row.wrap_continuation),
@intFromEnum(row.semantic_prompt),
@intFromBool(row.kitty_virtual_placeholder),
});
for (cells, 0..) |*cl, x| {
const st: vt.Style = if (cl.style_id == 0) .{} else pin.node.data.styles.get(pin.node.data.memory, cl.style_id).*;
const plain = cl.content_tag == .codepoint and cl.content.codepoint == 0 and
cl.wide == .narrow and !cl.protected and !cl.hyperlink and
@intFromEnum(cl.semantic_content) == 0 and st.eql(.{});
if (plain) continue;
try w.print(" {d}:{d}:", .{ x, @intFromEnum(cl.content_tag) });
switch (cl.content_tag) {
.codepoint, .codepoint_grapheme => try w.print("{x}", .{cl.content.codepoint}),
.bg_color_palette => try w.print("{d}", .{cl.content.color_palette}),
.bg_color_rgb => try w.print("{x:0>2}{x:0>2}{x:0>2}", .{ cl.content.color_rgb.r, cl.content.color_rgb.g, cl.content.color_rgb.b }),
}
if (cl.content_tag == .codepoint_grapheme) {
if (pin.node.data.lookupGrapheme(cl)) |g| {
for (g) |cp| try w.print("+{x}", .{cp});
} else try w.writeAll("+?");
}
try w.print(":{d}:{s}{s}{d}:", .{
@intFromEnum(cl.wide),
if (cl.protected) "p" else "",
if (cl.hyperlink) "h" else "",
@intFromEnum(cl.semantic_content),
});
try styleStr(w, st);
if (cl.hyperlink) {
const page = &pin.node.data;
if (page.lookupHyperlink(cl)) |hid| {
const e = page.hyperlink_set.get(page.memory, hid);
try w.print(" @{s}", .{e.uri.slice(page.memory)});
switch (e.id) {
.explicit => |ex| try w.print("@E:{s}", .{ex.slice(page.memory)}),
.implicit => |n| try w.print("@I:{d}", .{n}),
}
} else try w.writeAll(" @?");
}
}
try w.writeAll("\n");
}
}
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const alloc = gpa.allocator();
const args = try std.process.argsAlloc(alloc);
defer std.process.argsFree(alloc, args);
if (args.len != 5) return error.Usage;
const cols = try std.fmt.parseInt(u16, args[1], 10);
const rows = try std.fmt.parseInt(u16, args[2], 10);
const max_sb = try std.fmt.parseInt(usize, args[3], 10);
const input = try std.fs.cwd().readFileAlloc(alloc, args[4], 1 << 30);
defer alloc.free(input);
var t: vt.Terminal = try .init(alloc, .{ .cols = cols, .rows = rows, .max_scrollback = max_sb });
defer t.deinit(alloc);
var s: vt.ReadonlyStream = .initAlloc(alloc, .init(&t));
defer s.deinit();
try s.nextSlice(input);
var buf: [8192]u8 = undefined;
var fw = std.fs.File.stdout().writer(&buf);
const w = &fw.interface;
try w.print("size {d} {d}\n", .{ t.cols, t.rows });
try w.print("active {s}\n", .{@tagName(t.screens.active_key)});
const sr = t.scrolling_region;
try w.print("region t={d} b={d} l={d} r={d}\n", .{ sr.top, sr.bottom, sr.left, sr.right });
try w.writeAll("modes");
inline for (@typeInfo(vt.Mode).@"enum".fields) |f| {
const m: vt.Mode = @enumFromInt(f.value);
if (t.modes.get(m)) try w.print(" {s}", .{f.name});
}
try w.writeAll("\nsaved_modes");
inline for (@typeInfo(vt.Mode).@"enum".fields) |f| {
const bit = @field(t.modes.saved, f.name);
if (bit) try w.print(" {s}", .{f.name});
}
try w.writeAll("\ntabstops");
for (0..t.cols) |x| if (t.tabstops.get(x)) try w.print(" {d}", .{x});
try w.print("\nflags srp={d} mok2={d} mev={d} mfmt={d} msc={d}\n", .{
@intFromEnum(t.flags.shell_redraws_prompt),
@intFromBool(t.flags.modify_other_keys_2),
@intFromEnum(t.flags.mouse_event),
@intFromEnum(t.flags.mouse_format),
@intFromEnum(t.flags.mouse_shift_capture),
});
try w.print("status_display {d}\n", .{@intFromEnum(t.status_display)});
try w.print("mouse_shape {d}\n", .{@intFromEnum(t.mouse_shape)});
try w.print("pwd {s}\n", .{t.pwd.items});
if (t.previous_char) |pc| try w.print("prev {x}\n", .{pc}) else try w.writeAll("prev none\n");
inline for (.{ "foreground", "background", "cursor" }) |n| {
const d = @field(t.colors, n);
try w.print("color {s}", .{n});
if (d.override) |o| try w.print(" o={x:0>2}{x:0>2}{x:0>2}", .{ o.r, o.g, o.b }) else try w.writeAll(" o=-");
if (d.default) |o| try w.print(" d={x:0>2}{x:0>2}{x:0>2}", .{ o.r, o.g, o.b }) else try w.writeAll(" d=-");
try w.writeAll("\n");
}
try w.writeAll("palette");
for (t.colors.palette.current, 0..) |c, i| {
const o = t.colors.palette.original[i];
if (c.r != o.r or c.g != o.g or c.b != o.b)
try w.print(" {d}={x:0>2}{x:0>2}{x:0>2}", .{ i, c.r, c.g, c.b });
}
try w.writeAll("\n");
if (t.screens.get(.primary)) |sc| try dumpScreen(w, "primary", sc);
if (t.screens.get(.alternate)) |sc| try dumpScreen(w, "alternate", sc);
try w.writeAll("[plain]\n");
const str = try t.plainString(alloc);
defer alloc.free(str);
try w.writeAll(str);
try w.writeAll("\n[end]\n");
try w.flush();
}