//! ANSI escape removal for `once` output.
//!
//! `once` renders through lipgloss, so its output carries SGR colour codes and
//! cursor control even when piped. Clients want plain text.
/// Strips ANSI escape sequences and carriage returns from a line of output.
pub(crate) fn strip(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
match c {
'\u{1b}' => skip_escape(&mut chars),
'\r' => {}
other => out.push(other),
}
}
out.trim_end().to_owned()
}
/// Consumes the remainder of an escape sequence that has already had its
/// leading `ESC` taken off the iterator.
fn skip_escape(chars: &mut std::str::Chars<'_>) {
match chars.next() {
// CSI: parameters and intermediates, terminated by a final byte.
Some('[') => {
for c in chars.by_ref() {
if c.is_ascii_alphabetic() || c == '~' {
break;
}
}
}
// OSC: terminated by BEL or ST.
Some(']') => {
while let Some(c) = chars.next() {
if c == '\u{7}' {
break;
}
if c == '\u{1b}' && chars.next() == Some('\\') {
break;
}
}
}
// Two-character sequences consume exactly the byte already taken.
Some(_) | None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_survives_untouched() {
assert_eq!(strip("Update complete."), "Update complete.");
}
#[test]
fn sgr_colour_codes_are_removed() {
assert_eq!(strip("\u{1b}[1;32mDeployed\u{1b}[0m"), "Deployed");
}
#[test]
fn cursor_control_and_carriage_returns_are_removed() {
assert_eq!(strip("\u{1b}[2K\rPulling image"), "Pulling image");
}
#[test]
fn osc_hyperlinks_are_removed() {
assert_eq!(strip("\u{1b}]8;;https://example.com\u{7}link"), "link");
}
#[test]
fn trailing_whitespace_is_trimmed() {
assert_eq!(strip("done "), "done");
}
}