218 lines
7.3 KiB
Rust
218 lines
7.3 KiB
Rust
|
|
//! Differential test: the anstyle-parse-based `split_into_line_segments`
|
||
|
|
//! against a reference copy of the previous termwiz-based implementation.
|
||
|
|
//!
|
||
|
|
//! Production code uses `anstyle-parse` for ANSI line splitting. This test
|
||
|
|
//! embeds the prior termwiz-based splitter as a reference and asserts
|
||
|
|
//! identical observable output so the rewrite cannot drift. `termwiz` is a
|
||
|
|
//! dev-dependency only (powers this test; not linked into shipped binaries).
|
||
|
|
//!
|
||
|
|
//! Inputs are restricted to what the production splitter actually sees:
|
||
|
|
//! complete escape sequences (the old implementation `debug_assert`ed on
|
||
|
|
//! trailing incomplete ones) and no raw C1 controls encoded as UTF-8 (termwiz
|
||
|
|
//! maps e.g. U+0085 to a control action while VTE prints it; that corner was
|
||
|
|
//! unspecified before and is not exercised by terminal output we render).
|
||
|
|
|
||
|
|
use xai_ratatui_inline::split_into_line_segments;
|
||
|
|
|
||
|
|
// ─── Reference: the previous termwiz-based implementation, verbatim ────────
|
||
|
|
|
||
|
|
struct RefSegment<'a> {
|
||
|
|
content: &'a str,
|
||
|
|
ends_with_crlf: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn reference_split<'a>(input: &'a str, term_width: usize) -> Vec<RefSegment<'a>> {
|
||
|
|
use termwiz::escape::{Action, ControlCode, parser::Parser};
|
||
|
|
use unicode_width::UnicodeWidthChar as _;
|
||
|
|
|
||
|
|
let mut parser = Parser::new();
|
||
|
|
let mut remaining_bytes = input.as_bytes();
|
||
|
|
|
||
|
|
let mut segments = Vec::<RefSegment>::new();
|
||
|
|
let mut segment_start = 0_usize;
|
||
|
|
let mut segment_end = 0_usize;
|
||
|
|
let mut visual_width = 0_usize;
|
||
|
|
let mut has_visual = false;
|
||
|
|
let mut prev_is_cr = false;
|
||
|
|
|
||
|
|
macro_rules! push_segment {
|
||
|
|
($end:expr, $crlf:expr) => {
|
||
|
|
#[allow(unused_assignments)]
|
||
|
|
{
|
||
|
|
segments.push(RefSegment {
|
||
|
|
content: &input[segment_start..$end],
|
||
|
|
ends_with_crlf: $crlf,
|
||
|
|
});
|
||
|
|
visual_width = 0;
|
||
|
|
has_visual = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
while let Some((ansi_action, consumed)) = parser.parse_first(remaining_bytes) {
|
||
|
|
remaining_bytes = &remaining_bytes[consumed..];
|
||
|
|
let mut is_cr = false;
|
||
|
|
|
||
|
|
match ansi_action {
|
||
|
|
Action::Control(ControlCode::LineFeed) => {
|
||
|
|
push_segment!(segment_end - usize::from(prev_is_cr), true);
|
||
|
|
segment_end += consumed;
|
||
|
|
segment_start = segment_end;
|
||
|
|
}
|
||
|
|
Action::Control(ControlCode::CarriageReturn) => {
|
||
|
|
segment_end += consumed;
|
||
|
|
visual_width = 0;
|
||
|
|
is_cr = true;
|
||
|
|
}
|
||
|
|
Action::Print(ch) => {
|
||
|
|
let char_width = ch.width().unwrap_or(0);
|
||
|
|
let new_width = visual_width + char_width;
|
||
|
|
if new_width > term_width && has_visual {
|
||
|
|
push_segment!(segment_end, false);
|
||
|
|
segment_start = segment_end;
|
||
|
|
segment_end += consumed;
|
||
|
|
visual_width = char_width;
|
||
|
|
has_visual = true;
|
||
|
|
if char_width > term_width {
|
||
|
|
push_segment!(segment_end, false);
|
||
|
|
segment_start = segment_end;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
segment_end += consumed;
|
||
|
|
visual_width = new_width;
|
||
|
|
has_visual = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Action::PrintString(_) => unreachable!(),
|
||
|
|
_ => {
|
||
|
|
segment_end += consumed;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
prev_is_cr = is_cr;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(remaining_bytes.is_empty(), "{remaining_bytes:?}");
|
||
|
|
|
||
|
|
if segment_end > segment_start {
|
||
|
|
let input_start = input.as_ptr();
|
||
|
|
if let Some(last) = segments.last_mut() {
|
||
|
|
let last_start = last.content.as_ptr();
|
||
|
|
let last_end = unsafe { last_start.add(last.content.len()) };
|
||
|
|
if !last.ends_with_crlf && !has_visual {
|
||
|
|
assert_eq!(segment_start, (last_end as usize - input_start as usize));
|
||
|
|
let last_offset = last_start as usize - input_start as usize;
|
||
|
|
last.content = &input[last_offset..];
|
||
|
|
} else {
|
||
|
|
push_segment!(segment_end, false);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
push_segment!(segment_end, false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
segments
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Comparison harness ─────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[track_caller]
|
||
|
|
fn assert_same(input: &str, widths: &[usize]) {
|
||
|
|
for &width in widths {
|
||
|
|
let actual = split_into_line_segments(input, width);
|
||
|
|
let expected = reference_split(input, width);
|
||
|
|
let actual_view: Vec<(&str, bool)> = actual
|
||
|
|
.iter()
|
||
|
|
.map(|s| (s.content, s.ends_with_crlf))
|
||
|
|
.collect();
|
||
|
|
let expected_view: Vec<(&str, bool)> = expected
|
||
|
|
.iter()
|
||
|
|
.map(|s| (s.content, s.ends_with_crlf))
|
||
|
|
.collect();
|
||
|
|
assert_eq!(
|
||
|
|
actual_view, expected_view,
|
||
|
|
"divergence for width {width}, input: {input:?}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const WIDTHS: &[usize] = &[1, 2, 3, 5, 8, 10, 20, 80, 200];
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn corpus_matches_reference() {
|
||
|
|
let corpus: &[&str] = &[
|
||
|
|
"",
|
||
|
|
"hello",
|
||
|
|
"hello world, this is a longer line that will wrap several times",
|
||
|
|
"line1\nline2\nline3",
|
||
|
|
"line1\r\nline2\r\n",
|
||
|
|
"12345\r67",
|
||
|
|
"\r\r\n\n\r",
|
||
|
|
"😊😊😊 emoji wall 😊😊😊",
|
||
|
|
"hello 你好 混合 width",
|
||
|
|
"\x1b[31mred\x1b[0m plain \x1b[1;32;44mstyled\x1b[m",
|
||
|
|
"\x1b[31m\x1b[1m\x1b[4mnested styles no text\x1b[0m",
|
||
|
|
"12345678\x1b[0m90",
|
||
|
|
"text\x1b]8;;https://example.com\x07link\x1b]8;;\x07 after",
|
||
|
|
"osc title\x1b]0;window title\x07body",
|
||
|
|
"cursor \x1b[2Amoves \x1b[10;20H everywhere",
|
||
|
|
"tab\tand\x08backspace and \x07bell",
|
||
|
|
"\x1b[38;5;196mext colors\x1b[38;2;10;20;30m truecolor\x1b[0m",
|
||
|
|
"interrupted \x1b[3\nmid-sequence",
|
||
|
|
"\x1b[31m\nstyle then newline",
|
||
|
|
"trailing style 12345678\x1b[0m",
|
||
|
|
"\x1b(Bcharset\x1b)0 escapes",
|
||
|
|
"zero\u{200b}width\u{fe0f}chars",
|
||
|
|
"combining a\u{0301}e\u{0301} accents",
|
||
|
|
];
|
||
|
|
for input in corpus {
|
||
|
|
assert_same(input, WIDTHS);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Deterministic pseudo-random ANSI soup (xorshift, no extra deps).
|
||
|
|
#[test]
|
||
|
|
fn randomized_ansi_soup_matches_reference() {
|
||
|
|
let mut state = 0x243F_6A88_85A3_08D3_u64; // seed: pi digits
|
||
|
|
|
||
|
|
let mut next = move || {
|
||
|
|
state ^= state << 13;
|
||
|
|
state ^= state >> 7;
|
||
|
|
state ^= state << 17;
|
||
|
|
state
|
||
|
|
};
|
||
|
|
|
||
|
|
const PIECES: &[&str] = &[
|
||
|
|
"word",
|
||
|
|
"a",
|
||
|
|
"longer-token",
|
||
|
|
" ",
|
||
|
|
" ",
|
||
|
|
"\n",
|
||
|
|
"\r",
|
||
|
|
"\r\n",
|
||
|
|
"😊",
|
||
|
|
"你好",
|
||
|
|
"é",
|
||
|
|
"\u{200b}",
|
||
|
|
"\x1b[31m",
|
||
|
|
"\x1b[0m",
|
||
|
|
"\x1b[1;44;38;5;10m",
|
||
|
|
"\x1b[2K",
|
||
|
|
"\x1b[10D",
|
||
|
|
"\x1b]0;title\x07",
|
||
|
|
"\x1b]8;;http://x\x07",
|
||
|
|
"\t",
|
||
|
|
"\x07",
|
||
|
|
];
|
||
|
|
|
||
|
|
for _ in 0..2000 {
|
||
|
|
let mut input = String::new();
|
||
|
|
let len = (next() % 30) as usize;
|
||
|
|
for _ in 0..len {
|
||
|
|
input.push_str(PIECES[(next() % PIECES.len() as u64) as usize]);
|
||
|
|
}
|
||
|
|
let width = 1 + (next() % 40) as usize;
|
||
|
|
assert_same(&input, &[width]);
|
||
|
|
}
|
||
|
|
}
|