Skip to main content

uncurses/ansi/
strip.rs

1//! ANSI escape stripping built on the byte tokenizer.
2//!
3//! ## Category
4//!
5//! [`strip`] removes ANSI escape/string/control sequences while preserving
6//! printable text and non-ESC control bytes such as newlines and tabs.
7//!
8//! ## Parser conventions
9//!
10//! Tokenization recognizes CSI, OSC, DCS, SOS, PM, APC, and two-byte ESC
11//! sequences in both 7-bit and 8-bit forms. Escape tokens contribute no output.
12//!
13//! ## Mode interaction
14//!
15//! Stripping does not emulate terminal modes. It is a byte-stream transformation
16//! suitable for display-width and plain-text extraction paths.
17//!
18//! Sequence boundaries and widths come from [`crate::ansi::text`];
19//! which byte ends a control string, and when a byte in `0x80..=0x9F`
20//! is a C1 control rather than part of a character, are documented there.
21
22use super::text::{Token, WidthMode, tokenize};
23
24/// Return `s` with ANSI escape sequences removed.
25///
26/// CSI, OSC, DCS, SOS, PM, APC, and short ESC sequences are dropped. Printable
27/// UTF-8 text and non-ESC control bytes such as `\n`, `\r`, and `\t` are
28/// preserved.
29pub fn strip(s: &str) -> String {
30    let mut out = String::with_capacity(s.len());
31    for tok in tokenize(s.as_bytes(), WidthMode::default(), false) {
32        match tok {
33            Token::Text { text, .. } => out.push_str(bs(text)),
34            Token::Control(b) => out.push(b as char),
35            Token::Escape(_) => {}
36        }
37    }
38    out
39}
40
41#[inline]
42fn bs(b: &[u8]) -> &str {
43    // The tokenizer never splits a character: text tokens are whole grapheme
44    // clusters, and every sequence scanner steps a whole UTF-8 character at a
45    // time. Nothing enforced that, and when a scanner did split one - 0x9C is
46    // 8-bit ST and also a continuation byte, so an OSC title containing a
47    // check mark ended mid-character - the ill-formed bytes arrived here and
48    // this was undefined behaviour. Checked where checking is free.
49    debug_assert!(
50        std::str::from_utf8(b).is_ok(),
51        "token split a UTF-8 character: {b:?}"
52    );
53    // SAFETY: `b` is a token slice of `&str` input, taken on character
54    // boundaries, as asserted above.
55    unsafe { std::str::from_utf8_unchecked(b) }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn strip_sgr() {
64        assert_eq!(strip("\x1b[31mhello\x1b[0m"), "hello");
65    }
66
67    #[test]
68    fn strip_osc() {
69        assert_eq!(strip("\x1b]0;title\x07hello"), "hello");
70        assert_eq!(strip("\x1b]0;title\x1b\\hello"), "hello");
71    }
72
73    #[test]
74    fn strip_preserves_newlines() {
75        assert_eq!(strip("a\nb\tc"), "a\nb\tc");
76    }
77
78    #[test]
79    fn strip_unicode() {
80        assert_eq!(strip("\x1b[1m中文\x1b[m"), "中文");
81    }
82
83    #[test]
84    fn strip_empty() {
85        assert_eq!(strip(""), "");
86    }
87
88    #[test]
89    fn strip_only_escapes() {
90        assert_eq!(strip("\x1b[31m\x1b[m"), "");
91    }
92
93    #[test]
94    fn strip_nested_csi() {
95        assert_eq!(strip("a\x1b[1;2;3;4mb\x1b[0;1mc"), "abc");
96    }
97
98    #[test]
99    fn strip_two_byte_esc() {
100        // ESC = (DECKPAM)
101        assert_eq!(strip("a\x1b=b"), "ab");
102    }
103}