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}