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
6def1f3b5d
commit
4eb03f637d
19 files changed
+2026
-158
No files matched your search
Generated
+9
@@ -1519,6 +1519,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghostty-difftest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ghostty-terminal",
|
||||
"ghostty-unicode",
|
||||
"ghostty-vt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghostty-font"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ workspace = true
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = [".", "crates/*"]
|
||||
members = [".", "crates/*", "tools/difftest"]
|
||||
|
||||
# The upstream Ghostty version this rewrite tracks (Zig: `build.zig.zon`
|
||||
# `.version`); consumed by `crates/ghostty-cli/build.rs`.
|
||||
|
||||
@@ -1094,10 +1094,28 @@ impl PageList {
|
||||
page.size.rows = 0;
|
||||
} else {
|
||||
let page = &mut self.node_mut(id).ok_or(Error::InvalidId)?.data;
|
||||
if a == 0 {
|
||||
// Zig (`eraseRows`) SWAPS the surviving rows into place
|
||||
// rather than rotating, then clears the cells of the
|
||||
// rows left over past the new size. Row metadata (e.g.
|
||||
// the `wrap` flag) of those leftover rows is NOT reset
|
||||
// and becomes visible again when the page regrows into
|
||||
// them, so the exact arrangement must match.
|
||||
let cols = usize::from(page.size.cols);
|
||||
let scroll_amount = rows - b;
|
||||
for i in 0..scroll_amount {
|
||||
page.rows.swap(i, i + b);
|
||||
page.rows[i].set_dirty(true);
|
||||
}
|
||||
for y in scroll_amount..rows {
|
||||
page.clear_cells(y, 0, cols, false)?;
|
||||
}
|
||||
} else {
|
||||
for y in a..b {
|
||||
page.clear_cells(y, 0, usize::from(page.size.cols), false)?;
|
||||
}
|
||||
page.rows[a..rows].rotate_left(b - a);
|
||||
}
|
||||
page.size.rows -= (b - a) as u16;
|
||||
for row in &mut page.rows[a..usize::from(page.size.rows)] {
|
||||
row.set_dirty(true);
|
||||
|
||||
@@ -1022,4 +1022,94 @@ mod tests {
|
||||
|
||||
s.next_slice(b"\x1b[?W").unwrap();
|
||||
}
|
||||
|
||||
// ---- Regressions found by the Zig-vs-Rust differential fuzzer
|
||||
// (tools/difftest, docs/rewrite/notes/difftest-DF.md). Expected values
|
||||
// come from the Zig reference driver.
|
||||
|
||||
fn screen_row_wrap(t: &Terminal, y: u32) -> bool {
|
||||
use crate::point::{Coordinate, Point};
|
||||
let pages = &t.screen().pages;
|
||||
let pin = pages
|
||||
.pin(Point::Screen(Coordinate { x: 0, y }))
|
||||
.expect("row exists");
|
||||
pin.row_and_cell(pages).expect("row").0.wrap()
|
||||
}
|
||||
|
||||
fn cell_cps(t: &Terminal, row: u32) -> Vec<u32> {
|
||||
use crate::point::{Coordinate, Point};
|
||||
(0..t.cols)
|
||||
.map(|x| {
|
||||
t.screen()
|
||||
.pages
|
||||
.get_cell(Point::Active(Coordinate { x, y: row }))
|
||||
.map_or(0, crate::page::Cell::codepoint)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difftest_invalid_utf8_overlong_swallows_trail_byte() {
|
||||
// Zig's SIMD path (utfcpp replace_invalid) emits ONE U+FFFD for an
|
||||
// overlong lead plus its continuation byte.
|
||||
let mut t = term(10, 2);
|
||||
t.vt_stream().next_slice(b"\xc0\xa9").unwrap();
|
||||
assert_eq!(1, t.screen().cursor.x);
|
||||
assert_eq!(0xFFFD, cell_cps(&t, 0)[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difftest_invalid_utf8_truncated_then_control() {
|
||||
// e4 a9 followed by a non-trail byte: the truncated sequence is a
|
||||
// single replacement and the following byte decodes normally.
|
||||
let mut t = term(10, 2);
|
||||
t.vt_stream().next_slice(b"\xe4\xa9A\xf0\x9f").unwrap();
|
||||
let cps = cell_cps(&t, 0);
|
||||
assert_eq!(&cps[..2], &[0xFFFD, 'A' as u32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difftest_erase_scrollback_keeps_stale_row_flags() {
|
||||
// Zig's eraseRows swaps rows and leaves the stale `wrap` flag in the
|
||||
// leftover rows, which is visible once the page regrows.
|
||||
let mut t = term(1, 1);
|
||||
t.vt_stream().next_slice(b"xt\n\x1b[3Jex").unwrap();
|
||||
assert!(screen_row_wrap(&t, 0));
|
||||
assert!(!screen_row_wrap(&t, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difftest_grapheme_wrap_with_margins_moves_prev_cell() {
|
||||
// Devanagari conjunct widening the cell at the last column while a
|
||||
// left/right margin keeps the wrap from scrolling that cell: Zig moves
|
||||
// the grapheme from the original cell pointer (not `up(1)`).
|
||||
use crate::point::{Coordinate, Point};
|
||||
let mut t = term(20, 8);
|
||||
t.vt_stream()
|
||||
.next_slice("\x1b[?2027h\x1b[?69h\x1b[;10s\x1b[50;41H\u{915}\u{94d}\u{937}".as_bytes())
|
||||
.unwrap();
|
||||
let pages = &t.screen().pages;
|
||||
let pin = pages.pin(Point::Active(Coordinate { x: 0, y: 7 })).unwrap();
|
||||
let (_, cell) = pin.row_and_cell(pages).unwrap();
|
||||
assert_eq!(cell.codepoint(), 0x915);
|
||||
assert_eq!(crate::page::Wide::Wide, cell.wide());
|
||||
assert_eq!(Some(&[0x94d, 0x937][..]), pin.grapheme(pages));
|
||||
}
|
||||
|
||||
/// Upstream (Zig) bug, deliberately NOT replicated: Zig's
|
||||
/// `Terminal.print` grapheme path keeps `prev.cell` across a page
|
||||
/// reallocation (hyperlink/string capacity growth) and appends the
|
||||
/// grapheme into freed memory, silently losing U+FE0F here. Rust
|
||||
/// addresses cells by index and keeps it. See difftest-DF.md.
|
||||
#[test]
|
||||
fn difftest_upstream_uaf_grapheme_survives_page_growth() {
|
||||
let mut t = Terminal::new(Options {
|
||||
max_scrollback: 1,
|
||||
..Options::new(5, 30)
|
||||
})
|
||||
.unwrap();
|
||||
let input = b"\x1b\x5d\x38\x3b\x3b\x6c\x07\x35\x36\x37\x38\x39\x1b\x5b\x38\x65\x5f\x2c\xba\x9f\x87\xb8\x80\xbc\xbb\x1b\x5b\x35\x32\x72\x54\x68\x65\x20\x71\x75\x69\x63\x6b\x20\x62\x72\x6f\x77\x6e\x20\x66\x6f\x78\x1b\x5c\x61\x20\x40\x1b\x5b\x53\x1b\x4b\x30\x31\x32\x33\x34\x35\x36\x37\x31\x53\x1b\x73\x5d\x5b\x32\x6d\x9b\xff\x80\x80\x1b\x5b\x3f\x32\x30\x32\x37\x68\x1b\x5b\x3b\x33\x48\xf0\xc0\x5c\x77\x70\x5e\x6f\x20\x70\xe2\x9d\xa4\xef\xb8\x8f";
|
||||
t.vt_stream().next_slice(input).unwrap();
|
||||
assert!(t.plain_string().unwrap().contains("\u{2764}\u{fe0f}"));
|
||||
}
|
||||
}
|
||||
@@ -904,6 +904,17 @@ impl Terminal {
|
||||
// This is like print_cell but without clearing the
|
||||
// grapheme data from the cell, so we can move it
|
||||
// later.
|
||||
// Zig keeps a raw pointer (`prev.cell`) to this cell;
|
||||
// remember its page cell index, because the wrap below
|
||||
// may scroll (rows move, cell memory does not) and the
|
||||
// grapheme move must still use THIS cell.
|
||||
let prev_idx = {
|
||||
let scr = self.screen_mut();
|
||||
let (x, pin) = (scr.cursor.x, scr.cursor_pin());
|
||||
scr.page(pin.node)
|
||||
.cell_index(usize::from(x), usize::from(pin.y))
|
||||
.expect("prev cell")
|
||||
};
|
||||
{
|
||||
let cell = self.screen_mut().cursor_cell_mut();
|
||||
cell.set_wide(if row_wrap {
|
||||
@@ -929,8 +940,11 @@ impl Terminal {
|
||||
|
||||
if new_pin.node == old_pin.node {
|
||||
let page = self.screen_mut().page_mut(new_pin.node);
|
||||
let old_idx = page.cell_index(old_x, old_y).expect("old cell");
|
||||
let new_idx = page.cell_index(new_x, new_y).expect("new cell");
|
||||
// Zig moves from `prev.cell`, NOT from `old_pin`
|
||||
// (they differ when margins keep the row from
|
||||
// scrolling the cell out of `old_pin`).
|
||||
let old_idx = prev_idx;
|
||||
page.move_grapheme(old_idx, new_idx)?;
|
||||
page.cells[old_idx].set_content_tag(ContentTag::Codepoint);
|
||||
page.cells[new_idx].set_content_tag(ContentTag::CodepointGrapheme);
|
||||
|
||||
+179
-135
@@ -1,7 +1,10 @@
|
||||
//! SIMD-optimized routines (ZIG/src/simd). The Rust port is scalar-only:
|
||||
//! `memchr` provides the vectorized byte search, and UTF-8 decoding follows
|
||||
//! the Zig scalar fallback (`utf8DecodeUntilControlSeqScalar`), which is
|
||||
//! tested to match the SIMD behavior.
|
||||
//! `memchr` provides the vectorized byte search, and UTF-8 decoding
|
||||
//! reproduces the observable behaviour of the C++ SIMD path (`vt.cpp`:
|
||||
//! simdutf + utfcpp `replace_invalid` + `trim_partial_utf8`), which is what a
|
||||
//! default Ghostty build runs. (It differs from Zig's scalar fallback
|
||||
//! `utf8DecodeUntilControlSeqScalar` for ill-formed input; found by the
|
||||
//! differential fuzzer, see docs/rewrite/notes/difftest-DF.md.)
|
||||
//!
|
||||
//! Not ported: `codepoint_width` (belongs to `ghostty-unicode`) and
|
||||
//! `base64` (the `base64` crate is used by `kitty::graphics_command`).
|
||||
@@ -22,113 +25,155 @@ pub struct DecodeResult {
|
||||
pub decoded: usize,
|
||||
}
|
||||
|
||||
/// Length of a UTF-8 sequence given its leading byte, `None` if the byte
|
||||
/// cannot start a sequence (Zig `std.unicode.utf8ByteSequenceLength`).
|
||||
fn utf8_byte_sequence_length(first: u8) -> Option<usize> {
|
||||
match first {
|
||||
0x00..=0x7F => Some(1),
|
||||
0xC0..=0xDF => Some(2),
|
||||
0xE0..=0xEF => Some(3),
|
||||
0xF0..=0xF7 => Some(4),
|
||||
_ => None,
|
||||
/// `simdutf::scalar::utf8::trim_partial_utf8`: the length of `input` with a
|
||||
/// trailing (possibly) incomplete UTF-8 sequence removed. Purely byte-value
|
||||
/// based (it does not validate), exactly like the C++.
|
||||
fn trim_partial_utf8(input: &[u8]) -> usize {
|
||||
let length = input.len();
|
||||
if length < 3 {
|
||||
match length {
|
||||
2 => {
|
||||
if input[1] >= 0xc0 {
|
||||
return 1;
|
||||
}
|
||||
if input[0] >= 0xe0 {
|
||||
return 0;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
1 => {
|
||||
if input[0] >= 0xc0 {
|
||||
return 0;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
_ => return length,
|
||||
}
|
||||
}
|
||||
if input[length - 1] >= 0xc0 {
|
||||
return length - 1;
|
||||
}
|
||||
if input[length - 2] >= 0xe0 {
|
||||
return length - 2;
|
||||
}
|
||||
if input[length - 3] >= 0xf0 {
|
||||
return length - 3;
|
||||
}
|
||||
length
|
||||
}
|
||||
|
||||
/// Decode `bytes` (exactly one sequence whose length matches its leading
|
||||
/// byte) strictly; `None` if ill-formed (overlong, surrogate, out of range or
|
||||
/// a bad continuation byte). Equivalent to `std::str::from_utf8` on one char.
|
||||
/// Decode `input` into `output` replacing invalid sequences with U+FFFD the
|
||||
/// way `DecodeUTF8` in `vt.cpp` does: simdutf for the all-valid fast path and
|
||||
/// `utf8::replace_invalid` (utfcpp) otherwise. Because replacement output is
|
||||
/// valid UTF-8, this is equivalent to a single utfcpp pass:
|
||||
///
|
||||
/// - an invalid lead byte (`0x80..=0xC1`-style continuation bytes, `0xF8..`)
|
||||
/// becomes one U+FFFD and only that byte is skipped;
|
||||
/// - an incomplete sequence, overlong encoding, surrogate or out-of-range
|
||||
/// code point becomes ONE U+FFFD and the lead byte plus every following
|
||||
/// continuation byte is skipped;
|
||||
/// - a sequence truncated by the end of input becomes one U+FFFD.
|
||||
fn decode_utf8_replace(input: &[u8], output: &mut [u32]) -> usize {
|
||||
#[inline]
|
||||
fn utf8_decode(bytes: &[u8]) -> Option<u32> {
|
||||
#[inline]
|
||||
fn cont(b: u8) -> bool {
|
||||
fn is_trail(b: u8) -> bool {
|
||||
b & 0xC0 == 0x80
|
||||
}
|
||||
match *bytes {
|
||||
[b0] if b0 < 0x80 => Some(u32::from(b0)),
|
||||
[b0 @ 0xC2..=0xDF, b1] if cont(b1) => {
|
||||
Some((u32::from(b0 & 0x1F) << 6) | u32::from(b1 & 0x3F))
|
||||
}
|
||||
[b0 @ 0xE0..=0xEF, b1, b2] if cont(b1) && cont(b2) => {
|
||||
let ok = match b0 {
|
||||
0xE0 => b1 >= 0xA0,
|
||||
0xED => b1 < 0xA0,
|
||||
_ => true,
|
||||
let mut count = 0;
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
let lead = input[i];
|
||||
let len = if lead < 0x80 {
|
||||
1
|
||||
} else if lead >> 5 == 0x6 {
|
||||
2
|
||||
} else if lead >> 4 == 0xe {
|
||||
3
|
||||
} else if lead >> 3 == 0x1e {
|
||||
4
|
||||
} else {
|
||||
// INVALID_LEAD
|
||||
output[count] = 0xFFFD;
|
||||
count += 1;
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
ok.then(|| {
|
||||
(u32::from(b0 & 0x0F) << 12) | (u32::from(b1 & 0x3F) << 6) | u32::from(b2 & 0x3F)
|
||||
})
|
||||
}
|
||||
[b0 @ 0xF0..=0xF4, b1, b2, b3] if cont(b1) && cont(b2) && cont(b3) => {
|
||||
let ok = match b0 {
|
||||
0xF0 => b1 >= 0x90,
|
||||
0xF4 => b1 < 0x90,
|
||||
_ => true,
|
||||
// Gather the sequence, tracking utfcpp's error classes.
|
||||
let mut cp = if len == 1 {
|
||||
u32::from(lead)
|
||||
} else {
|
||||
u32::from(lead) & (0x7f >> len)
|
||||
};
|
||||
ok.then(|| {
|
||||
(u32::from(b0 & 0x07) << 18)
|
||||
| (u32::from(b1 & 0x3F) << 12)
|
||||
| (u32::from(b2 & 0x3F) << 6)
|
||||
| u32::from(b3 & 0x3F)
|
||||
})
|
||||
let mut err = None; // Some(true) = NOT_ENOUGH_ROOM, Some(false) = other
|
||||
for k in 1..len {
|
||||
match input.get(i + k) {
|
||||
None => {
|
||||
err = Some(true);
|
||||
break;
|
||||
}
|
||||
_ => None,
|
||||
Some(&b) if !is_trail(b) => {
|
||||
err = Some(false);
|
||||
break;
|
||||
}
|
||||
Some(&b) => cp = (cp << 6) | u32::from(b & 0x3f),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode UTF-8 from `input` into `output` until an ESC (0x1B) is found.
|
||||
/// `output.len()` must be at least `input.len()`.
|
||||
///
|
||||
/// Invalid sequences have their leading byte replaced with U+FFFD and
|
||||
/// decoding continues after that byte. Decoding stops (without consuming)
|
||||
/// at an incomplete trailing sequence or at a byte that can't start a
|
||||
/// sequence (e.g. 0xFF or a stray continuation byte); the caller must handle
|
||||
/// those bytes itself (see `stream.rs`).
|
||||
pub fn utf8_decode_until_control_seq(input: &[u8], output: &mut [u32]) -> DecodeResult {
|
||||
assert!(output.len() >= input.len());
|
||||
|
||||
let idx = index_of(input, 0x1B).unwrap_or(input.len());
|
||||
let decode = &input[..idx];
|
||||
|
||||
let mut decode_offset = 0;
|
||||
let mut decode_count = 0;
|
||||
while decode_offset < decode.len() {
|
||||
// ASCII fast path: a run of single-byte sequences.
|
||||
let rem = &decode[decode_offset..];
|
||||
if rem[0] < 0x80 {
|
||||
let run = rem.iter().take_while(|&&b| b < 0x80).count();
|
||||
for (o, &b) in output[decode_count..decode_count + run].iter_mut().zip(rem) {
|
||||
*o = u32::from(b);
|
||||
}
|
||||
decode_count += run;
|
||||
decode_offset += run;
|
||||
if err.is_none() {
|
||||
let valid = cp <= 0x10_FFFF && !(0xD800..=0xDFFF).contains(&cp);
|
||||
let overlong = match cp {
|
||||
0..=0x7f => len != 1,
|
||||
0x80..=0x7ff => len != 2,
|
||||
0x800..=0xffff => len != 3,
|
||||
_ => false,
|
||||
};
|
||||
if valid && !overlong {
|
||||
output[count] = cp;
|
||||
count += 1;
|
||||
i += len;
|
||||
continue;
|
||||
}
|
||||
let Some(cp_len) = utf8_byte_sequence_length(rem[0]) else {
|
||||
err = Some(false);
|
||||
}
|
||||
output[count] = 0xFFFD;
|
||||
count += 1;
|
||||
if err == Some(true) {
|
||||
// NOT_ENOUGH_ROOM: one replacement for the truncated tail.
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
while i < input.len() && is_trail(input[i]) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Decode UTF-8 from `input` into `output` until an ESC (0x1B) is found,
|
||||
/// faithfully following `ghostty_simd_decode_utf8_until_control_seq`
|
||||
/// (`ZIG/src/simd/vt.cpp`, the real SIMD path of a default Ghostty build):
|
||||
///
|
||||
/// - if an ESC is found, everything before it is decoded (invalid and
|
||||
/// truncated sequences become U+FFFD, see [`decode_utf8_replace`]) and the
|
||||
/// ESC position is returned;
|
||||
/// - otherwise a trailing partial sequence is trimmed with
|
||||
/// `trim_partial_utf8` (a byte-value heuristic, it may trim valid trailing
|
||||
/// bytes too) and the rest is decoded; the caller feeds the untouched tail
|
||||
/// to the scalar decoder.
|
||||
///
|
||||
/// `output.len()` must be at least `input.len()`.
|
||||
pub fn utf8_decode_until_control_seq(input: &[u8], output: &mut [u32]) -> DecodeResult {
|
||||
assert!(output.len() >= input.len());
|
||||
if let Some(esc) = index_of(input, 0x1B) {
|
||||
let decoded = decode_utf8_replace(&input[..esc], output);
|
||||
return DecodeResult {
|
||||
consumed: esc,
|
||||
decoded,
|
||||
};
|
||||
|
||||
// Partial input: defer to the future.
|
||||
if rem.len() < cp_len {
|
||||
break;
|
||||
}
|
||||
|
||||
match utf8_decode(&rem[..cp_len]) {
|
||||
Some(cp) => {
|
||||
output[decode_count] = cp;
|
||||
decode_offset += cp_len;
|
||||
}
|
||||
None => {
|
||||
// Replace only the leading byte and continue after it.
|
||||
output[decode_count] = 0xFFFD;
|
||||
decode_offset += 1;
|
||||
}
|
||||
}
|
||||
decode_count += 1;
|
||||
}
|
||||
|
||||
let trimmed = trim_partial_utf8(input);
|
||||
let decoded = decode_utf8_replace(&input[..trimmed], output);
|
||||
DecodeResult {
|
||||
consumed: decode_offset,
|
||||
decoded: decode_count,
|
||||
consumed: trimmed,
|
||||
decoded,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,54 +182,53 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utf8_decode_matches_std() {
|
||||
fn std_decode(bytes: &[u8]) -> Option<u32> {
|
||||
std::str::from_utf8(bytes)
|
||||
.ok()
|
||||
.and_then(|s| s.chars().next())
|
||||
.map(|c| c as u32)
|
||||
}
|
||||
for b0 in 0..=255u8 {
|
||||
let Some(len) = utf8_byte_sequence_length(b0) else {
|
||||
fn decode_valid_matches_std() {
|
||||
let mut out = [0u32; 8];
|
||||
for cp in (0..=0x10FFFFu32)
|
||||
.step_by(7)
|
||||
.chain([0x7f, 0x80, 0x7ff, 0x800, 0xffff, 0x10000, 0x10FFFF])
|
||||
{
|
||||
let Some(c) = char::from_u32(cp) else {
|
||||
continue;
|
||||
};
|
||||
match len {
|
||||
1 => assert_eq!(utf8_decode(&[b0]), std_decode(&[b0])),
|
||||
2 => {
|
||||
for b1 in 0..=255u8 {
|
||||
assert_eq!(
|
||||
utf8_decode(&[b0, b1]),
|
||||
std_decode(&[b0, b1]),
|
||||
"{b0:x} {b1:x}"
|
||||
);
|
||||
if c == '\x1b' {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
for b1 in 0..=255u8 {
|
||||
for b2 in 0..=255u8 {
|
||||
assert_eq!(
|
||||
utf8_decode(&[b0, b1, b2]),
|
||||
std_decode(&[b0, b1, b2]),
|
||||
"{b0:x} {b1:x} {b2:x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for b1 in 0..=255u8 {
|
||||
for b2 in [0x00, 0x7F, 0x80, 0xA0, 0xBF, 0xC0] {
|
||||
for b3 in [0x00, 0x7F, 0x80, 0xBF, 0xC0] {
|
||||
assert_eq!(
|
||||
utf8_decode(&[b0, b1, b2, b3]),
|
||||
std_decode(&[b0, b1, b2, b3]),
|
||||
"{b0:x} {b1:x} {b2:x} {b3:x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut b = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut b);
|
||||
assert_eq!(decode_utf8_replace(s.as_bytes(), &mut out), 1);
|
||||
assert_eq!(out[0], cp);
|
||||
}
|
||||
}
|
||||
|
||||
// Regressions found by the differential fuzzer (utfcpp replace_invalid
|
||||
// semantics: ONE U+FFFD per invalid sequence, trail bytes swallowed).
|
||||
#[test]
|
||||
fn decode_replace_swallows_trail_bytes() {
|
||||
let mut out = [0u32; 16];
|
||||
// overlong lead + trail
|
||||
assert_eq!(decode_utf8_replace(b"\xc0\xa9", &mut out), 1);
|
||||
assert_eq!(out[0], 0xFFFD);
|
||||
// truncated 3-byte sequence followed by a non-trail byte
|
||||
assert_eq!(decode_utf8_replace(b"\xe4\xa9\x41", &mut out), 2);
|
||||
assert_eq!(&out[..2], &[0xFFFD, 0x41]);
|
||||
// surrogate
|
||||
assert_eq!(decode_utf8_replace(b"\xed\xa0\x80", &mut out), 1);
|
||||
// invalid lead only skips itself
|
||||
assert_eq!(decode_utf8_replace(b"\xff\x80", &mut out), 2);
|
||||
// stray trail bytes are each invalid leads
|
||||
assert_eq!(decode_utf8_replace(b"\x80\x80", &mut out), 2);
|
||||
// truncated at end of input: one replacement
|
||||
assert_eq!(decode_utf8_replace(b"\xf0\x9f", &mut out), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_partial_is_byte_value_based() {
|
||||
assert_eq!(trim_partial_utf8(b"ab\xe4\x41"), 2);
|
||||
assert_eq!(trim_partial_utf8(b"abc"), 3);
|
||||
assert_eq!(trim_partial_utf8(b"ab\xff"), 2);
|
||||
assert_eq!(trim_partial_utf8(b"\xe4\xb8"), 0);
|
||||
assert_eq!(trim_partial_utf8(b""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -347,29 +347,33 @@ impl<H: Handler> Stream<H> {
|
||||
debug_assert!(input.len() <= cp_buf.len());
|
||||
|
||||
let mut offset = 0;
|
||||
while offset < input.len() {
|
||||
// If the scalar UTF-8 decoder was in the middle of processing a
|
||||
// code sequence, we continue until it's not.
|
||||
if self.utf8decoder.in_sequence() {
|
||||
|
||||
// If the scalar UTF-8 decoder was in the middle of processing a code
|
||||
// sequence, we continue until it's not.
|
||||
while self.utf8decoder.in_sequence() {
|
||||
if offset >= input.len() {
|
||||
return Ok(());
|
||||
}
|
||||
self.next_utf8(input[offset])?;
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if offset >= input.len() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If we're not in the ground state then we process until we are.
|
||||
// This can happen if the last chunk of input put us in the middle
|
||||
// of a control sequence.
|
||||
if self.parser.state != State::Ground {
|
||||
// If we're not in the ground state then we process until we are. This
|
||||
// can happen if the last chunk of input put us in the middle of a
|
||||
// control sequence.
|
||||
offset += self.consume_until_ground(&input[offset..])?;
|
||||
continue;
|
||||
if offset >= input.len() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if input[offset] == 0x1B {
|
||||
offset += self.consume_all_escapes(&input[offset..])?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ground state: everything up to the next ESC is UTF-8.
|
||||
// If we're in the ground state then we can use SIMD to process input
|
||||
// until we see an ESC (0x1B), since all other characters up to that
|
||||
// point are just UTF-8.
|
||||
while self.parser.state == State::Ground && offset < input.len() {
|
||||
let res = simd::utf8_decode_until_control_seq(&input[offset..], cp_buf);
|
||||
for &cp in &cp_buf[..res.decoded] {
|
||||
if cp <= 0xF {
|
||||
@@ -378,18 +382,25 @@ impl<H: Handler> Stream<H> {
|
||||
self.print(cp)?;
|
||||
}
|
||||
}
|
||||
// Consume the bytes we just processed.
|
||||
offset += res.consumed;
|
||||
|
||||
if offset >= input.len() {
|
||||
break;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If our offset is NOT an escape then we must have a partial or
|
||||
// invalid UTF-8 sequence. Pass that byte to the scalar decoder,
|
||||
// which handles replacement and carries partial state over.
|
||||
// If our offset is NOT an escape then we must have a partial
|
||||
// UTF-8 sequence. In that case, we pass the whole remainder off
|
||||
// to the scalar parser.
|
||||
if input[offset] != 0x1B {
|
||||
self.next_utf8(input[offset])?;
|
||||
offset += 1;
|
||||
for &c in &input[offset..] {
|
||||
self.next_utf8(c)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Process control sequences until we run out.
|
||||
offset += self.consume_all_escapes(&input[offset..])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Differential fuzzing of the terminal core (agent DF)
|
||||
|
||||
Zig (Ghostty 1.3.1, the reference) vs Rust (`ghostty-terminal` + `ghostty-vt`
|
||||
read-only stream), on random escape-heavy byte streams.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `tools/difftest/setup.sh` | Idempotent setup: Zig 0.15.2 -> `target/zig-0.15.2/`, a COPY of the Zig source -> `target/zig-src/`, builds the Zig driver (ReleaseSafe -> `target/difftest-zig/`, ReleaseFast -> `target/difftest-zig-fast/`). Nothing in `/tmp`, reference tree untouched. |
|
||||
| `tools/difftest/zig/` | Zig driver (`build.zig`, `src/main.zig`), depends on the source copy via a path dependency and the `ghostty-vt` module (SIMD enabled, like a default Ghostty build). |
|
||||
| `tools/difftest/src/lib.rs`, `bin/difftest_rs.rs` | Rust twin: `difftest_rs <cols> <rows> <max_scrollback> <file>`; `lib.rs::dump` is the canonical dump (must stay byte-identical to the Zig one). |
|
||||
| `tools/difftest/src/inputgen.rs` | Deterministic (splitmix64) escape-heavy generator: ASCII/UTF-8/wide/combining/emoji/ZWJ/invalid UTF-8/C1 bytes, C0 controls, CSI with edge-value params and intermediates, SGR (incl. colon forms), DECSET/DECRST/XTSAVE/XTRESTORE/DECRQM, ESC sequences, charsets, OSC (colors, hyperlinks, pwd, 133...), DCS, APC, tabs, scroll regions + left/right margins, IL/DL/ICH/DCH/ECH/REP, alt screen, resets, byte mutations. Geometry (1x1 .. 300x3) and scrollback (0..100000) vary per case. |
|
||||
| `tools/difftest/src/bin/difftest_fuzz.rs` | Harness: `run` (N cases, threads, seed, `--size CxR`, `--sb`, `--tokens`, `--zig PATH`), `check`, `min` (ddmin minimiser on divergence), `minz`/`minr` (minimise an abort). |
|
||||
| `tools/difftest/triage.sh` | Minimise every saved failure and print the first differing lines. |
|
||||
| `just difftest [CASES] [SEEDS]` | Setup + run in both assertion modes. |
|
||||
|
||||
Failures are saved in `target/difftest-work/fails-<seed>/` (`.bin`, `.zig.txt`, `.rs.txt`).
|
||||
|
||||
## Dump format
|
||||
|
||||
Per screen (primary + alternate if it exists): cursor (x, y, pending wrap,
|
||||
protected, style, hyperlink flag), saved cursor, charset state, protected mode,
|
||||
kitty keyboard flags, every row of scrollback+active (row flags wrap /
|
||||
wrap_continuation / semantic_prompt / kitty placeholder; every non-blank cell:
|
||||
content tag, codepoint(s) + grapheme tail, wide, protected/hyperlink flags,
|
||||
semantic content, full style, hyperlink URI + id). Terminal level: size, active
|
||||
screen, scrolling region, all mode values + saved modes, tabstops, mouse/shell
|
||||
flags, status display, mouse shape, pwd, previous char, dynamic colors,
|
||||
modified palette entries, plus the formatter's plain text (`plainString`).
|
||||
|
||||
## Comparison rules
|
||||
|
||||
* Identical dump = pass.
|
||||
* If the Zig driver aborts (failed assertion / segfault in Zig code, e.g.
|
||||
Devanagari conjunct at the right edge with left/right margins, REP with a
|
||||
huge count while a page grows under a hyperlink, grapheme-break assertion in
|
||||
`Terminal.print`) there is no defined behaviour to compare: counted under
|
||||
"zig aborted" and not a divergence.
|
||||
* A Rust panic where Zig succeeded is a divergence.
|
||||
* Two passes: assertions on (Rust debug vs Zig ReleaseSafe) and assertions off
|
||||
(Rust `production` profile vs Zig ReleaseFast).
|
||||
|
||||
## Divergences found and fixed
|
||||
|
||||
1. **UTF-8 decoding of ill-formed input** (`ghostty-vt/src/simd.rs`,
|
||||
`stream.rs`). The Rust port modelled Zig's *scalar fallback*
|
||||
(`utf8DecodeUntilControlSeqScalar`), but a default Ghostty build runs the
|
||||
C++ SIMD path (`vt.cpp`: simdutf + utfcpp `replace_invalid` +
|
||||
`trim_partial_utf8`). Differences: ONE U+FFFD per invalid sequence with all
|
||||
following continuation bytes swallowed (`c0 a9`, `e4 a9 f0`, ...); decoding of
|
||||
everything before an ESC without deferring truncated tails; byte-value based
|
||||
tail trimming when no ESC; and the remaining tail (not just one byte) goes to
|
||||
the scalar decoder. Rewrote `utf8_decode_until_control_seq` and
|
||||
`next_slice_capped` to mirror C++/Zig. Tests: `simd::tests::*`,
|
||||
`stream_readonly::tests::difftest_invalid_utf8_*`.
|
||||
2. **`PageList::erase_rows` stale row flags** (`ghostty-terminal/src/page_list.rs`).
|
||||
Zig swaps surviving rows into place and only clears the leftover rows' cells,
|
||||
so e.g. `wrap` stays set on rows that later become visible again when the
|
||||
page regrows (CSI 3 J then more output). Rust rotated and cleared. Now swaps
|
||||
exactly like Zig for erases starting at the top of a page. Test:
|
||||
`difftest_erase_scrollback_keeps_stale_row_flags`.
|
||||
3. **Grapheme move on wrap with left/right margins** (`terminal.rs`
|
||||
`print_grapheme`). Zig moves the grapheme from the original `prev.cell`
|
||||
pointer; Rust used `old_pin = new_pin.up(1)` with `x = right_limit - 1`,
|
||||
which is a different cell when margins stop the wrap from scrolling the
|
||||
previous cell (stream returned `InvalidCoordinate`). Now remembers the
|
||||
original cell index. Test: `difftest_grapheme_wrap_with_margins_moves_prev_cell`.
|
||||
|
||||
## Upstream bugs found (Zig memory-safety / UB; NOT replicated in Rust)
|
||||
|
||||
1. **Stale cell pointer after page growth in the grapheme-attach path**
|
||||
(`src/terminal/Terminal.zig:482` sets `prev.cell = cursor.page_cell`, then
|
||||
`printCell` of the spacer tail can call `increaseCapacity` (hyperlink/string
|
||||
bytes) which reallocates the page, and `:539`
|
||||
`appendGrapheme(prev.cell, c)` writes through the dangling pointer; the
|
||||
grapheme is lost). Repro (5x30, scrollback 1): the 107-byte minimized stream
|
||||
in `stream_readonly::tests::difftest_upstream_uaf_grapheme_survives_page_growth`
|
||||
(a hyperlinked cell then `U+2764 U+FE0F` wrapping at the edge). Rust keeps the
|
||||
grapheme. Harness: mismatches where Zig logged `adjusting page capacity` are
|
||||
saved as `suspect*` and not counted as divergences (see `zig_run`).
|
||||
2. Zig aborts / segfaults (not compared): the `assert(!graphemeBreak(...))`
|
||||
in `Terminal.print` (`Terminal.zig:375`) after mode 2027 with some
|
||||
U+2028/U+2060/BOM sequences; `assert(y < size.rows)` (`page.zig:1012`) and
|
||||
segfaults for Devanagari conjuncts at the right edge with left/right margins;
|
||||
`assert(!cell.hyperlink)` (`Terminal.zig:834`) / segfault on REP with a huge
|
||||
count while a hyperlinked page grows. Repro e.g. 20x8:
|
||||
`ESC[?2027h ESC[?69h ESC[;10s ESC[50;41H` + U+0915 U+094D U+0937
|
||||
(Zig faults; Rust now handles it, test
|
||||
`difftest_grapheme_wrap_with_margins_moves_prev_cell`).
|
||||
|
||||
## Known Zig-side aborts (not Rust bugs)
|
||||
|
||||
See "Comparison rules". Roughly 0.1-0.4 % of generated cases (mostly the
|
||||
Devanagari/margins one, which the generator hits on purpose) abort in Zig.
|
||||
|
||||
## Final run
|
||||
|
||||
Final campaign (after all fixes): 10 geometries x 20k (assertions on), 5 x 20k
|
||||
(assertions off, production Rust vs ReleaseFast Zig), 3 x 30k mixed geometries
|
||||
= 390k cases, 0 divergences, 2 suspects (both the upstream UAF above, 0.1-0.5 %
|
||||
of cases abort in Zig and are skipped). Not done: replaying Zig test-suite
|
||||
inputs (those tests are already ported as Rust unit tests).
|
||||
|
||||
`cargo test --workspace --no-fail-fast`: everything passes except
|
||||
`ghostty-rs --test gtk_replay` (needs a GPU/Vulkan: `ZINK: vkEnumeratePhysicalDevices
|
||||
failed`), an environment limitation unrelated to this work.
|
||||
@@ -47,6 +47,26 @@ test:
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Differential fuzzing of the terminal core, Zig (reference) vs Rust. Sets up
|
||||
# Zig 0.15.2 + a copy of the Zig source + the Zig driver under target/ on the
|
||||
# first run (network needed), then fuzzes SEEDS x CASES random escape-heavy
|
||||
# streams twice: assertions on (debug Rust vs ReleaseSafe Zig) and assertions
|
||||
# off (production Rust vs ReleaseFast Zig). Divergences are saved under
|
||||
# target/difftest-work/fails-<seed>/ (minimise with `difftest_fuzz min`). See
|
||||
# docs/rewrite/notes/difftest-DF.md.
|
||||
difftest CASES="20000" SEEDS="1 2 3":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
tools/difftest/setup.sh
|
||||
cargo build -p ghostty-difftest
|
||||
cargo build --profile production -p ghostty-difftest
|
||||
jobs="$(nproc)"
|
||||
for s in {{ SEEDS }}; do
|
||||
timeout 3000 target/debug/difftest_fuzz run --seed "$s" --cases {{ CASES }} --jobs "$jobs" --tokens 120
|
||||
timeout 3000 target/production/difftest_fuzz run --seed "$s" --cases {{ CASES }} --jobs "$jobs" --tokens 120 \
|
||||
--zig target/difftest-zig-fast/bin/difftest_zig
|
||||
done
|
||||
|
||||
# Build the build-data generator (Zig: `ghostty-build-data`,
|
||||
# src/main_build_data.zig) in release mode.
|
||||
build-data:
|
||||
|
||||
@@ -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
|
||||
Executable
+39
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+11
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user