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:
1 parent
580ed4d52d
commit
d13dcc8a7f
19 files changed
+2033
-165
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user