Skip to main content

rich/
ansi.rs

1//! Decoding ANSI escape sequences back into styled [`Text`].
2//!
3//! Port of upstream `rich/ansi.py`. [`AnsiDecoder`] tokenizes a terminal string
4//! into plain runs and SGR (Select Graphic Rendition) codes, accumulating a
5//! [`Style`] as it goes and emitting one [`Text`] per line. This is the inverse
6//! of the [`Console`](crate::console::Console)'s styled output.
7//!
8//! Scope: SGR styling (attributes, 16/256/truecolor foreground + background) is
9//! fully handled, as are **OSC 8 hyperlinks** (`\x1b]8;<params>;<url>\x1b\`): the
10//! URL is attached to the running [`Style`] (and cleared by the empty closing
11//! sequence). Re-rendering reproduces upstream byte-for-byte except the random
12//! `id=` field upstream adds, which we omit for determinism (docs/DIVERGENCES.md
13//! #20).
14
15use fancy_regex::Regex;
16use std::sync::OnceLock;
17
18use crate::color::Color;
19use crate::style::Style;
20use crate::text::Text;
21
22/// The SGR parameter → `Style` spec map. Port of `rich.ansi.SGR_STYLE_MAP`.
23fn sgr_style(code: u16) -> Option<&'static str> {
24    let spec = match code {
25        1 => "bold",
26        2 => "dim",
27        3 => "italic",
28        4 => "underline",
29        5 => "blink",
30        6 => "blink2",
31        7 => "reverse",
32        8 => "conceal",
33        9 => "strike",
34        21 => "underline2",
35        22 => "not dim not bold",
36        23 => "not italic",
37        24 => "not underline",
38        25 => "not blink",
39        26 => "not blink2",
40        27 => "not reverse",
41        28 => "not conceal",
42        29 => "not strike",
43        30 => "color(0)",
44        31 => "color(1)",
45        32 => "color(2)",
46        33 => "color(3)",
47        34 => "color(4)",
48        35 => "color(5)",
49        36 => "color(6)",
50        37 => "color(7)",
51        39 => "default",
52        40 => "on color(0)",
53        41 => "on color(1)",
54        42 => "on color(2)",
55        43 => "on color(3)",
56        44 => "on color(4)",
57        45 => "on color(5)",
58        46 => "on color(6)",
59        47 => "on color(7)",
60        49 => "on default",
61        51 => "frame",
62        52 => "encircle",
63        53 => "overline",
64        54 => "not frame not encircle",
65        55 => "not overline",
66        90 => "color(8)",
67        91 => "color(9)",
68        92 => "color(10)",
69        93 => "color(11)",
70        94 => "color(12)",
71        95 => "color(13)",
72        96 => "color(14)",
73        97 => "color(15)",
74        100 => "on color(8)",
75        101 => "on color(9)",
76        102 => "on color(10)",
77        103 => "on color(11)",
78        104 => "on color(12)",
79        105 => "on color(13)",
80        106 => "on color(14)",
81        107 => "on color(15)",
82        _ => return None,
83    };
84    Some(spec)
85}
86
87/// The tokenizer regex (port of `rich.ansi.re_ansi`): a private escape such
88/// as `\x1b7` (save cursor), which is dropped; an OSC string
89/// (`\x1b]…\x1b\`); or an escape sequence (single-char or CSI).
90fn re_ansi() -> &'static Regex {
91    static RE: OnceLock<Regex> = OnceLock::new();
92    RE.get_or_init(|| {
93        Regex::new(r"(?:\x1b[0-?])|(?:\x1b\](.*?)\x1b\\)|(?:\x1b([(@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))")
94            .expect("valid ansi regex")
95    })
96}
97
98/// One token from [`tokenize`]: plain text, an SGR parameter string, or the
99/// body of an OSC string (`\x1b]<body>\x1b\`).
100enum Token {
101    Plain(String),
102    /// The parameters of an `\x1b[…m` sequence (without the `[` and `m`).
103    Sgr(String),
104    /// The body of an OSC string (e.g. `8;;https://example.com`).
105    Osc(String),
106}
107
108/// Tokenize a line into plain runs, SGR parameter strings, and OSC bodies,
109/// mirroring `_ansi_tokenize`. Non-SGR CSI sequences are dropped.
110fn tokenize(line: &str) -> Vec<Token> {
111    let mut tokens = Vec::new();
112    let mut position = 0;
113    for caps in re_ansi().captures_iter(line).flatten() {
114        let whole = caps.get(0).expect("group 0 always present");
115        let (start, end) = (whole.start(), whole.end());
116        if start > position {
117            tokens.push(Token::Plain(line[position..start].to_string()));
118        }
119        match caps.get(2) {
120            Some(sgr) => {
121                let sgr = sgr.as_str();
122                if sgr == "(" {
123                    // Charset-select escape consumes the following character
124                    // too: one code point, as upstream's `end + 1` indexes a
125                    // Python string (not one byte, which could split a
126                    // character).
127                    position = end + line[end..].chars().next().map_or(0, char::len_utf8);
128                    continue;
129                }
130                if let Some(params) = sgr.strip_prefix('[').and_then(|s| s.strip_suffix('m')) {
131                    tokens.push(Token::Sgr(params.to_string()));
132                }
133                // Other CSI sequences (e.g. `[2J`) are dropped.
134            }
135            None => {
136                // An OSC string — group 1 is its body (a private escape has
137                // neither group and yields nothing) (between `\x1b]` and the
138                // terminating `\x1b\`).
139                if let Some(osc) = caps.get(1) {
140                    tokens.push(Token::Osc(osc.as_str().to_string()));
141                }
142            }
143        }
144        position = end;
145    }
146    if position < line.len() {
147        tokens.push(Token::Plain(line[position..].to_string()));
148    }
149    tokens
150}
151
152/// Translates ANSI codes into styled [`Text`]. Mirrors `rich.ansi.AnsiDecoder`.
153///
154/// The decoder is stateful: a style set on one line persists to the next, just
155/// like a real terminal (and like upstream).
156#[derive(Default)]
157pub struct AnsiDecoder {
158    style: Style,
159}
160
161impl AnsiDecoder {
162    pub fn new() -> Self {
163        AnsiDecoder {
164            style: Style::new(),
165        }
166    }
167
168    /// Decode a multi-line terminal string into one [`Text`] per line. Port
169    /// of `AnsiDecoder.decode`: `re.split(r"(?<=\n)", text)` then
170    /// `line.rstrip("\n")`. Only `\n` ends a line (a `\r` before it is kept,
171    /// so it resets the line), and text ending in a newline, or empty text,
172    /// yields a final empty line.
173    ///
174    /// rich 12 split with `splitlines` instead; for that, decode
175    /// `text.lines()` one at a time with [`decode_line`](Self::decode_line).
176    pub fn decode(&mut self, terminal_text: &str) -> Vec<Text> {
177        self.decode_split_newlines(terminal_text)
178    }
179
180    /// The same as [`decode`](Self::decode), which it was the only form of
181    /// before 0.0.22.
182    pub fn decode_split_newlines(&mut self, terminal_text: &str) -> Vec<Text> {
183        terminal_text
184            .split_inclusive('\n')
185            .chain((terminal_text.is_empty() || terminal_text.ends_with('\n')).then_some(""))
186            .map(|line| self.decode_line(line.trim_end_matches('\n')))
187            .collect()
188    }
189
190    /// Decode a single line containing ANSI codes.
191    pub fn decode_line(&mut self, line: &str) -> Text {
192        // A carriage return resets the line: keep only what follows the last one.
193        let line = line.rsplit('\r').next().unwrap_or(line);
194        let mut text = Text::new("");
195        for token in tokenize(line) {
196            match token {
197                Token::Plain(plain) => {
198                    let style = if self.style.is_null() {
199                        None
200                    } else {
201                        Some(self.style.clone().into())
202                    };
203                    text.append(&plain, style);
204                }
205                Token::Sgr(params) => self.apply_sgr(&params),
206                Token::Osc(osc) => self.apply_osc(&osc),
207            }
208        }
209        text
210    }
211
212    /// Apply an OSC body. Only hyperlinks (`8;<params>;<url>`) are meaningful:
213    /// the params (e.g. `id=…`) are ignored, and the URL is attached to — or,
214    /// when empty, cleared from — the running style. Port of the OSC branch of
215    /// `decode_line`.
216    fn apply_osc(&mut self, osc: &str) {
217        if let Some(rest) = osc.strip_prefix("8;") {
218            // partition on the first ';': everything after it is the link.
219            if let Some(idx) = rest.find(';') {
220                let link = &rest[idx + 1..];
221                let link = (!link.is_empty()).then(|| link.to_string());
222                self.style = self.style.update_link(link);
223            }
224        }
225    }
226
227    /// Apply an SGR parameter string (e.g. `"1;31"`) to the running style.
228    fn apply_sgr(&mut self, params: &str) {
229        // Lenient parse: keep digit runs (clamped to 255) and empty fields (0).
230        let codes: Vec<u16> = params
231            .split(';')
232            .filter(|c| c.is_empty() || c.bytes().all(|b| b.is_ascii_digit()))
233            // `min(255, int(code))`: a number too long to parse is above 255.
234            .map(|c| {
235                if c.is_empty() {
236                    0
237                } else {
238                    c.parse::<u32>().map_or(255, |code| code.min(255)) as u16
239                }
240            })
241            .collect();
242
243        let mut iter = codes.into_iter();
244        while let Some(code) = iter.next() {
245            if code == 0 {
246                self.style = Style::new();
247            } else if let Some(spec) = sgr_style(code) {
248                if let Ok(parsed) = Style::parse(spec) {
249                    self.style = self.style.combine(&parsed);
250                }
251            } else if code == 38 {
252                if let Some(color) = read_extended_color(&mut iter) {
253                    self.style = self.style.combine(&Style::from_color(Some(color), None));
254                }
255            } else if code == 48 {
256                if let Some(color) = read_extended_color(&mut iter) {
257                    self.style = self.style.combine(&Style::from_color(None, Some(color)));
258                }
259            }
260        }
261    }
262}
263
264/// Read the color following a `38`/`48` code: `5;<n>` (8-bit) or `2;<r>;<g>;<b>`
265/// (truecolor). Returns `None` if the sequence is truncated (lenient, like
266/// upstream's `suppress(StopIteration)`).
267fn read_extended_color(iter: &mut impl Iterator<Item = u16>) -> Option<Color> {
268    match iter.next()? {
269        5 => Some(Color::from_ansi(iter.next()? as u8)),
270        2 => {
271            let r = iter.next()? as u8;
272            let g = iter.next()? as u8;
273            let b = iter.next()? as u8;
274            Some(Color::from_rgb(r, g, b))
275        }
276        _ => None,
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::color::ColorSystem;
284    use crate::console::Console;
285
286    #[test]
287    fn decode_splits_as_rich_15() {
288        // Captured from rich 15.0.0's `AnsiDecoder().decode(s)` (`.markup`).
289        let cases: [(&str, &[&str]); 5] = [
290            ("a\r\nb\n", &["", "b", ""]),
291            ("", &[""]),
292            ("x", &["x"]),
293            ("a\n\nb", &["a", "", "b"]),
294            (
295                "\x1b[1mz\nw\x1b[0m\n",
296                &["[bold]z[/bold]", "[bold]w[/bold]", ""],
297            ),
298        ];
299        for (input, expected) in cases {
300            let got: Vec<String> = AnsiDecoder::new()
301                .decode(input)
302                .iter()
303                .map(Text::markup)
304                .collect();
305            assert_eq!(got, expected, "{input:?}");
306        }
307    }
308
309    fn round_trip(input: &str) -> String {
310        let mut decoder = AnsiDecoder::new();
311        let console = Console::builder()
312            .force_terminal(true)
313            .color_system(Some(ColorSystem::Truecolor))
314            .width(80)
315            .build();
316        decoder
317            .decode(input)
318            .iter()
319            .map(|t| console.render_to_string(t))
320            .collect::<Vec<_>>()
321            .join("\n")
322    }
323
324    #[test]
325    fn charset_select_skips_one_character_as_rich_15() {
326        // Captured from rich 15.0.0's `AnsiDecoder().decode(s)` (`.markup`).
327        // A multi-byte character after `ESC (` used to panic (found by the
328        // `ansi` fuzz target).
329        let cases: [(&str, &[&str]); 4] = [
330            ("a\x1b(\u{e9}b", &["ab"]),
331            ("\x1b(\u{4e2d}\u{6587}", &["\u{6587}"]),
332            ("x\x1b(", &["x"]),
333            ("\x1b(\x1b[1mz", &["[bold]z[/bold]"]),
334        ];
335        for (input, expected) in cases {
336            let got: Vec<String> = AnsiDecoder::new()
337                .decode(input)
338                .iter()
339                .map(Text::markup)
340                .collect();
341            assert_eq!(got, expected, "{input:?}");
342        }
343    }
344
345    #[test]
346    fn plain_text_has_no_style() {
347        assert_eq!(round_trip("hello"), "hello");
348    }
349
350    #[test]
351    fn bold_red_round_trips() {
352        // Both the input and the re-render use rich's own SGR ordering.
353        assert_eq!(round_trip("\x1b[1;31mhi\x1b[0m"), "\x1b[1;31mhi\x1b[0m");
354    }
355
356    #[test]
357    fn eight_bit_and_truecolor() {
358        assert_eq!(
359            round_trip("\x1b[38;5;214mx\x1b[0m"),
360            "\x1b[38;5;214mx\x1b[0m"
361        );
362        assert_eq!(
363            round_trip("\x1b[38;2;255;136;0mx\x1b[0m"),
364            "\x1b[38;2;255;136;0mx\x1b[0m"
365        );
366    }
367
368    #[test]
369    fn style_persists_until_reset() {
370        // "a" is bold; without a reset, "b" on the next segment stays bold.
371        assert_eq!(round_trip("\x1b[1mab"), "\x1b[1mab\x1b[0m");
372    }
373
374    #[test]
375    fn non_sgr_csi_is_dropped() {
376        assert_eq!(round_trip("\x1b[2Jhi"), "hi");
377    }
378
379    #[test]
380    fn osc8_hyperlink_round_trips() {
381        // A styled hyperlink: the URL attaches to the running style, so the
382        // re-render wraps the styled text in OSC 8. Matches real rich 15.0.0
383        // except upstream's random `id=` field, which we omit (DIVERGENCES #20).
384        assert_eq!(
385            round_trip("\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"),
386            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"
387        );
388    }
389
390    #[test]
391    fn osc8_link_without_style_and_clear() {
392        // Unstyled link text between plain runs; the empty closing OSC clears the
393        // link so " after" is plain.
394        assert_eq!(
395            round_trip("before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"),
396            "before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"
397        );
398    }
399
400    #[test]
401    fn osc8_id_param_is_ignored() {
402        // Upstream includes a random `id=`; when decoding we drop the params and
403        // keep only the URL, re-emitting without an id.
404        assert_eq!(
405            round_trip("\x1b]8;id=42;https://x.io\x1b\\a\x1b]8;;\x1b\\"),
406            "\x1b]8;;https://x.io\x1b\\a\x1b]8;;\x1b\\"
407        );
408    }
409}