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)
295 lines
9.2 KiB
Rust
295 lines
9.2 KiB
Rust
//! 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
|
|
}
|