#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Frame {
pub text: String,
pub redraw: bool,
}
pub(crate) struct Splitter {
buf: Vec<u8>,
}
impl Splitter {
pub(crate) fn new() -> Self {
Self {
buf: Vec::with_capacity(256),
}
}
pub(crate) fn feed(&mut self, chunk: &[u8], out: &mut Vec<Frame>) {
for &b in chunk {
match b {
b'\n' => self.flush_into(out, false),
b'\r' => self.flush_into(out, true),
_ => self.buf.push(b),
}
}
}
pub(crate) fn flush_eof(&mut self, out: &mut Vec<Frame>) {
self.flush_into(out, false);
}
fn flush_into(&mut self, out: &mut Vec<Frame>, redraw: bool) {
if self.buf.is_empty() {
return;
}
let raw = String::from_utf8_lossy(&self.buf).into_owned();
self.buf.clear();
let cleaned = strip_ansi(&raw);
if !cleaned.is_empty() && !is_pure_chrome(&cleaned) {
out.push(Frame {
text: cleaned,
redraw,
});
}
}
}
fn is_pure_chrome(s: &str) -> bool {
!s.chars().any(char::is_alphanumeric)
}
pub(crate) fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() {
let b = bytes[j];
j += 1;
if (0x40..=0x7e).contains(&b) {
break;
}
}
i = j;
continue;
}
if let Some(c) = s[i..].chars().next() {
out.push(c);
i += c.len_utf8();
} else {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn split_all(input: &[u8]) -> Vec<Frame> {
let mut s = Splitter::new();
let mut out = Vec::new();
s.feed(input, &mut out);
s.flush_eof(&mut out);
out
}
fn texts(frames: &[Frame]) -> Vec<&str> {
frames.iter().map(|f| f.text.as_str()).collect()
}
fn frame(text: &str, redraw: bool) -> Frame {
Frame {
text: text.to_owned(),
redraw,
}
}
#[test]
fn strip_ansi_clear_line() {
assert_eq!(strip_ansi("hello\x1b[Kworld"), "helloworld");
}
#[test]
fn strip_ansi_sgr() {
assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
}
#[test]
fn strip_ansi_no_escape_passthrough() {
assert_eq!(strip_ansi("plain text"), "plain text");
}
#[test]
fn splits_on_lf() {
assert_eq!(texts(&split_all(b"a\nb\nc")), vec!["a", "b", "c"]);
}
#[test]
fn splits_on_cr() {
assert_eq!(texts(&split_all(b"a\rb\rc")), vec!["a", "b", "c"]);
}
#[test]
fn crlf_does_not_double() {
assert_eq!(texts(&split_all(b"a\r\nb\r\n")), vec!["a", "b"]);
}
#[test]
fn drops_empty_frames() {
assert!(split_all(b"\r\r\r").is_empty());
}
#[test]
fn ansi_stripped_per_frame() {
let out = split_all(b"\x1b[31mred\x1b[0m\nplain\n");
assert_eq!(texts(&out), vec!["red", "plain"]);
}
#[test]
fn mixed_terminators_tag_redraw_flag() {
let frames = split_all(b"a\rb\nc\rd\n");
assert_eq!(
frames,
vec![
frame("a", true),
frame("b", false),
frame("c", true),
frame("d", false),
]
);
}
#[test]
fn cr_redraw_sequence_then_lf_close() {
let input = b"5%\r10%\r50%\r100%\n";
let frames = split_all(input);
assert_eq!(
frames,
vec![
frame("5%", true),
frame("10%", true),
frame("50%", true),
frame("100%", false),
]
);
}
#[test]
fn substitute_progress_splits_into_three_frames() {
let input = b"substitute: \rsubstitute: \x1b[Klooking ... 0.0%\rsubstitute: \x1b[Klooking ... 50.0%\rsubstitute: \x1b[Klooking ... 100.0%\n";
let frames = split_all(input);
assert_eq!(frames.len(), 4);
assert!(frames[1].text.ends_with("0.0%"));
assert!(frames[2].text.ends_with("50.0%"));
assert!(frames[3].text.ends_with("100.0%"));
assert!(frames[0].redraw);
assert!(frames[1].redraw);
assert!(frames[2].redraw);
assert!(!frames[3].redraw);
assert!(!frames[3].text.contains('\x1b'));
assert!(!frames[3].text.contains('['));
}
#[test]
fn pure_chrome_unicode_bar_is_dropped() {
let bar = "\u{2595}\u{2588}\u{2588}\u{2588}\u{2588}\u{258f}";
let input = format!("{bar}\n").into_bytes();
let frames = split_all(&input);
assert!(frames.is_empty(), "bar-only frame should drop: {frames:?}");
}
#[test]
fn pure_chrome_ascii_bar_is_dropped() {
assert!(split_all(b"[####]\n").is_empty());
assert!(split_all(b"[####] \n").is_empty());
}
#[test]
fn bar_with_percent_is_kept() {
let out = split_all(b"[####] 50%\n");
assert_eq!(texts(&out), vec!["[####] 50%"]);
assert!(!out[0].redraw);
}
#[test]
fn status_text_is_kept_even_after_bar_drops() {
let input = "\u{2595}\u{2588}\u{2588}\u{258f}\rlooking for substitutes...\n";
let frames = split_all(input.as_bytes());
assert_eq!(texts(&frames), vec!["looking for substitutes..."]);
}
#[test]
fn partial_chunk_stitches_across_feeds() {
let mut s = Splitter::new();
let mut out = Vec::new();
s.feed(b"hel", &mut out);
assert!(out.is_empty());
s.feed(b"lo\nworld", &mut out);
assert_eq!(texts(&out), vec!["hello"]);
s.flush_eof(&mut out);
assert_eq!(texts(&out), vec!["hello", "world"]);
}
#[test]
fn lone_cr_at_start_is_noop() {
assert_eq!(texts(&split_all(b"\rabc\n")), vec!["abc"]);
}
#[test]
fn csi_sequence_spans_chunk_boundary() {
let mut s = Splitter::new();
let mut out = Vec::new();
s.feed(b"hi\x1b", &mut out);
assert!(out.is_empty(), "no terminator yet, no frame: {out:?}");
s.feed(b"[Kbye\n", &mut out);
assert_eq!(out.len(), 1);
let frame = &out[0];
assert!(!frame.text.contains('\x1b'), "ESC leaked: {frame:?}");
assert!(!frame.text.contains('['), "`[` leaked: {frame:?}");
assert_eq!(frame.text, "hibye");
assert!(!frame.redraw);
}
#[test]
#[allow(clippy::naive_bytecount)]
fn fixture_dry_build_hello_yields_many_substitute_frames() {
let raw = include_bytes!("../../tests/fixtures/dry-build-hello.txt");
let frames = split_all(raw);
let lf_count = raw.iter().filter(|&&b| b == b'\n').count();
assert!(
frames.len() > lf_count,
"expected splitter to surface more frames ({} found) than `\\n` count ({lf_count})",
frames.len()
);
for f in &frames {
assert!(
!f.text.contains('\x1b'),
"ANSI not stripped in frame: {f:?}"
);
}
let hundred = frames.iter().filter(|f| f.text.contains("100.0%")).count();
assert!(
hundred >= 2,
"expected multiple `100.0%` frames, got {hundred}"
);
}
}