use std::num::NonZeroU16;
use crate::string::TrimExt;
#[must_use]
pub fn to_plain_text(input: &[u8], rows: NonZeroU16, cols: NonZeroU16) -> String {
#[derive(Default)]
struct Capture {
plain_contents: String,
buffer: String,
state: vt100::capture::BasicFormattedCaptureState,
}
fn push_formatted(plain_contents: &mut String, formatted_chunk: &str) {
for plain_chunk in vt100::capture::basic_formatted_to_plain(formatted_chunk) {
let mut first = true;
for plain_line in plain_chunk.split('\n') {
if !std::mem::take(&mut first) {
plain_contents.trim_end_matches_in_place([' ', '\t']);
plain_contents.push('\n');
}
plain_contents.push_str(plain_line);
}
}
}
impl vt100::Callbacks for Capture {
fn on_scroll(&mut self, contents: vt100::capture::RowContents<'_>, alternate_screen: bool) {
if alternate_screen {
return;
}
contents
.write_formatted_basic(&mut self.buffer, &mut self.state)
.expect("writing to a String cannot fail");
push_formatted(&mut self.plain_contents, &self.buffer);
self.buffer.clear();
}
}
if input.is_empty() {
return String::new();
}
let mut parser = vt100::Parser::new_with_callbacks(rows, cols, 0, Capture::default());
onlcr(input).for_each(|chunk| parser.process(chunk));
let (screen, capture) = parser.screen_and_callbacks_mut();
screen
.write_contents_formatted_basic(
&mut capture.buffer,
vt100::capture::BasicFormattedCaptureRange::Full(&mut capture.state),
)
.expect("writing to a String cannot fail");
capture.buffer.push('\n');
push_formatted(&mut capture.plain_contents, &capture.buffer);
capture.plain_contents.trim_end_matches_in_place('\n');
std::mem::take(&mut capture.plain_contents)
}
pub fn onlcr(mut bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
std::iter::from_fn(move || {
if bytes.is_empty() {
return None;
}
for i in memchr::memchr_iter(b'\n', bytes) {
if i.checked_sub(1).is_some_and(|prev| bytes[prev] == b'\r') {
continue;
}
let before = &bytes[..i];
bytes = &bytes[i + 1..];
return Some([before, b"\r\n"]);
}
Some([std::mem::take(&mut bytes), b""])
})
.flatten()
.filter(|slice| !slice.is_empty())
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU16;
use proptest::prelude::*;
use rstest::rstest;
use super::*;
fn nz(size: u16) -> NonZeroU16 {
NonZeroU16::new(size).expect("test terminal size must be nonzero")
}
fn render(input: &str) -> String {
to_plain_text(input.as_bytes(), nz(24), nz(80))
}
fn assert_no_terminal_controls(text: &str) {
assert!(
!text.chars().any(|ch| ch.is_control() && ch != '\n' && ch != '\t'),
"text still contains terminal controls: {text:?}"
);
}
fn onlcr_reference(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut prev = None;
for &b in bytes {
if b == b'\n' && prev != Some(b'\r') {
out.push(b'\r');
}
out.push(b);
prev = Some(b);
}
out
}
fn onlcr_bytes(bytes: &[u8]) -> Vec<u8> {
onlcr(bytes).flatten().copied().collect()
}
#[rstest]
#[case::plain("echo hi", "echo hi")]
#[case::empty("", "")]
#[case::single_backspace("e\x08echo hi", "echo hi")]
#[case::backspace_storm(
"e\x08echo\x08 \x08\x08 \x08\x08\x08e \x08\x08 \x08e\x08echo hi",
"echo hi"
)]
#[case::ansi_and_percent_marker(
"\x1b[32mhi\x1b[0m\r\n% \r \r",
"hi"
)]
#[case::utf8_after_backspace("🦀x\x08 \x08 crab", "🦀 crab")]
#[case::carriage_return_rewrite("aaaa\rbbbb", "bbbb")]
#[case::bare_lf_is_newline("line one\nline two", "line one\nline two")]
#[case::tab_expands_to_spaces("a\tb", "a b")]
fn renders_expected_plain_text(#[case] input: &str, #[case] expected: &str) {
assert_eq!(render(input), expected);
assert_no_terminal_controls(&render(input));
}
#[rstest]
fn empty_input_is_empty_regardless_of_size(
#[values(nz(1), nz(24), nz(u16::MAX))] rows: NonZeroU16,
#[values(nz(1), nz(80), nz(u16::MAX))] cols: NonZeroU16,
) {
assert_eq!(to_plain_text(b"", rows, cols), "");
}
#[rstest]
fn wide_character_on_a_one_column_screen() {
assert_eq!(to_plain_text("⺀".as_bytes(), nz(24), nz(1)), "");
}
#[rstest]
fn trailing_blank_lines_are_trimmed() {
assert_eq!(render("hi\r\n\r\n\r\n"), "hi");
}
#[rstest]
fn interior_and_leading_blank_lines_are_kept() {
assert_eq!(render("\r\n\r\nfirst\r\n\r\n\r\nlast\r\n"), "\n\nfirst\n\n\nlast");
}
#[rstest]
fn row_padding_is_trimmed_but_wrapped_spaces_are_kept() {
assert_eq!(to_plain_text(b"a \r\nb \r\nc \r\n", nz(24), nz(20)), "a\nb\nc");
assert_eq!(to_plain_text(b"abc def", nz(24), nz(6)), "abc def");
}
#[rstest]
fn long_lines_wrap_at_the_column_boundary() {
let wrapped = to_plain_text(b"abcdefghij", nz(24), nz(4));
assert_eq!(wrapped, "abcdefghij");
}
#[rstest]
#[case::one_row(nz(1))]
#[case::shorter_than_the_output(nz(3))]
#[case::taller_than_the_output(nz(24))]
fn rows_scrolled_off_the_screen_are_still_returned(#[case] rows: NonZeroU16) {
let input: String = (0..20).map(|i| format!("line{i}\r\n")).collect();
let expected: Vec<String> = (0..20).map(|i| format!("line{i}")).collect();
assert_eq!(to_plain_text(input.as_bytes(), rows, nz(80)), expected.join("\n"));
}
#[rstest]
fn alternate_screen_output_is_dropped_once_it_is_left() {
let input = b"before\r\n\x1b[?1049hinside the pager\r\n\x1b[?1049lafter\r\n";
assert_eq!(to_plain_text(input, nz(24), nz(80)), "before\nafter");
}
#[rstest]
fn output_still_on_the_alternate_screen_is_returned() {
let input = b"before\r\n\x1b[?1049hinside the pager\r\n";
assert_eq!(to_plain_text(input, nz(24), nz(80)), "inside the pager");
}
#[rstest]
#[case::empty(b"")]
#[case::bare_lf(b"\n")]
#[case::crlf(b"\r\n")]
#[case::bare_lf_between_text(b"a\nb")]
#[case::crlf_between_text(b"a\r\nb")]
#[case::consecutive_bare_lfs(b"\n\n")]
#[case::consecutive_crlfs(b"\r\n\r\n")]
#[case::trailing_cr(b"a\r")]
#[case::leading_cr(b"\ra")]
#[case::crlf_then_bare_lf(b"a\r\n\nb")]
fn onlcr_matches_the_byte_at_a_time_implementation(#[case] input: &[u8]) {
assert_eq!(onlcr_bytes(input), onlcr_reference(input));
}
#[rstest]
fn onlcr_never_yields_an_empty_chunk() {
assert!(onlcr(b"\n\na\n\n").all(|chunk| !chunk.is_empty()));
}
proptest! {
#[rstest]
fn never_panics_and_strips_controls(
bytes in proptest::collection::vec(any::<u8>(), 0..4096),
rows in 1u16..=16,
cols in 1u16..=u16::MAX,
) {
let out = to_plain_text(&bytes, nz(rows), nz(cols));
prop_assert!(!out.chars().any(|c| c.is_control() && c != '\n' && c != '\t'));
}
#[rstest]
fn all_rows_are_returned_however_short_the_screen(
lines in 1usize..256,
rows in 1u16..=64,
) {
let input: String = (0..lines).map(|i| format!("line{i}\r\n")).collect();
let out = to_plain_text(input.as_bytes(), nz(rows), nz(80));
prop_assert_eq!(out.lines().count(), lines);
}
#[rstest]
fn to_plain_text_is_idempotent_on_clean_single_line(s in "[ -~]{0,80}") {
let once = to_plain_text(s.as_bytes(), nz(24), nz(200));
let twice = to_plain_text(once.as_bytes(), nz(24), nz(200));
prop_assert_eq!(once, twice);
}
#[rstest]
fn onlcr_agrees_with_the_byte_at_a_time_implementation(
bytes in proptest::collection::vec(prop_oneof![Just(b'\r'), Just(b'\n'), any::<u8>()], 0..256),
) {
prop_assert_eq!(onlcr_bytes(&bytes), onlcr_reference(&bytes));
}
#[rstest]
fn onlcr_is_idempotent(
bytes in proptest::collection::vec(prop_oneof![Just(b'\r'), Just(b'\n'), any::<u8>()], 0..256),
) {
let once = onlcr_bytes(&bytes);
prop_assert_eq!(onlcr_bytes(&once), once);
}
}
}