pub fn with_colour(text: &str) -> String {
strip(text, true)
}
pub fn plain(text: &str) -> String {
strip(text, false)
}
fn strip(text: &str, keep_colour: bool) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(current) = chars.next() {
match current {
'\u{1b}' => match chars.next() {
Some('[') => {
let sequence = read_csi(&mut chars);
if keep_colour && sequence.ends_with('m') && is_sgr(&sequence) {
out.push('\u{1b}');
out.push('[');
out.push_str(&sequence);
}
}
Some(']' | 'P' | 'X' | '^' | '_') => skip_string(&mut chars),
Some(_) | None => {}
},
'\u{9b}' => {
let sequence = read_csi(&mut chars);
if keep_colour && sequence.ends_with('m') && is_sgr(&sequence) {
out.push('\u{1b}');
out.push('[');
out.push_str(&sequence);
}
}
'\u{9d}' | '\u{90}' | '\u{98}' | '\u{9e}' | '\u{9f}' => skip_string(&mut chars),
'\t' | '\n' => out.push(current),
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
fn read_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut sequence = String::new();
while let Some(&next) = chars.peek() {
chars.next();
sequence.push(next);
if ('\u{40}'..='\u{7e}').contains(&next) {
break;
}
if !('\u{20}'..='\u{3f}').contains(&next) {
sequence.clear();
break;
}
}
sequence
}
fn is_sgr(sequence: &str) -> bool {
sequence
.strip_suffix('m')
.is_some_and(|parameters| parameters.chars().all(|c| c.is_ascii_digit() || c == ';'))
}
fn skip_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(current) = chars.next() {
match current {
'\u{7}' | '\u{9c}' => return,
'\u{1b}' => {
if chars.peek() == Some(&'\\') {
chars.next();
}
return;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_keep_the_colour_of_a_log_line() {
assert_eq!(
with_colour("\u{1b}[1;32mok\u{1b}[0m"),
"\u{1b}[1;32mok\u{1b}[0m"
);
}
#[test]
fn it_should_drop_an_osc_sequence_that_writes_the_clipboard() {
assert_eq!(with_colour("a\u{1b}]52;c;Zm9v\u{7}b"), "ab");
}
#[test]
fn it_should_drop_an_osc_sequence_ended_by_st() {
assert_eq!(with_colour("a\u{1b}]0;title\u{1b}\\b"), "ab");
}
#[test]
fn it_should_drop_cursor_movement_while_keeping_colour() {
assert_eq!(
with_colour("\u{1b}[2J\u{1b}[1A\u{1b}[31mred"),
"\u{1b}[31mred"
);
}
#[test]
fn it_should_drop_the_eight_bit_csi() {
assert_eq!(with_colour("x\u{9b}2Jy"), "xy");
}
#[test]
fn it_should_drop_a_carriage_return_and_a_backspace() {
assert_eq!(with_colour("ok\r\u{8}fail"), "okfail");
}
#[test]
fn it_should_keep_tabs_and_newlines() {
assert_eq!(with_colour("a\tb\nc"), "a\tb\nc");
}
#[test]
fn it_should_strip_colour_from_a_name() {
assert_eq!(plain("\u{1b}[31mapi\u{1b}[0m"), "api");
}
#[test]
fn it_should_drop_an_unterminated_osc_to_the_end() {
assert_eq!(plain("a\u{1b}]8;;http://x"), "a");
}
#[test]
fn it_should_leave_ordinary_unicode_alone() {
assert_eq!(plain("señal → ✔"), "señal → ✔");
}
}