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`): an OSC string
88/// (`\x1b]…\x1b\`) or an escape sequence (single-char or CSI).
89fn re_ansi() -> &'static Regex {
90    static RE: OnceLock<Regex> = OnceLock::new();
91    RE.get_or_init(|| {
92        Regex::new(r"(?:\x1b\](.*?)\x1b\\)|(?:\x1b([(@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))")
93            .expect("valid ansi regex")
94    })
95}
96
97/// One token from [`tokenize`]: plain text, an SGR parameter string, or the
98/// body of an OSC string (`\x1b]<body>\x1b\`).
99enum Token {
100    Plain(String),
101    /// The parameters of an `\x1b[…m` sequence (without the `[` and `m`).
102    Sgr(String),
103    /// The body of an OSC string (e.g. `8;;https://example.com`).
104    Osc(String),
105}
106
107/// Tokenize a line into plain runs, SGR parameter strings, and OSC bodies,
108/// mirroring `_ansi_tokenize`. Non-SGR CSI sequences are dropped.
109fn tokenize(line: &str) -> Vec<Token> {
110    let mut tokens = Vec::new();
111    let mut position = 0;
112    for caps in re_ansi().captures_iter(line).flatten() {
113        let whole = caps.get(0).expect("group 0 always present");
114        let (start, end) = (whole.start(), whole.end());
115        if start > position {
116            tokens.push(Token::Plain(line[position..start].to_string()));
117        }
118        match caps.get(2) {
119            Some(sgr) => {
120                let sgr = sgr.as_str();
121                if sgr == "(" {
122                    // Charset-select escape consumes the following byte too.
123                    position = (end + 1).min(line.len());
124                    continue;
125                }
126                if let Some(params) = sgr.strip_prefix('[').and_then(|s| s.strip_suffix('m')) {
127                    tokens.push(Token::Sgr(params.to_string()));
128                }
129                // Other CSI sequences (e.g. `[2J`) are dropped.
130            }
131            None => {
132                // An OSC string — group 1 is its body (between `\x1b]` and the
133                // terminating `\x1b\`).
134                if let Some(osc) = caps.get(1) {
135                    tokens.push(Token::Osc(osc.as_str().to_string()));
136                }
137            }
138        }
139        position = end;
140    }
141    if position < line.len() {
142        tokens.push(Token::Plain(line[position..].to_string()));
143    }
144    tokens
145}
146
147/// Translates ANSI codes into styled [`Text`]. Mirrors `rich.ansi.AnsiDecoder`.
148///
149/// The decoder is stateful: a style set on one line persists to the next, just
150/// like a real terminal (and like upstream).
151#[derive(Default)]
152pub struct AnsiDecoder {
153    style: Style,
154}
155
156impl AnsiDecoder {
157    pub fn new() -> Self {
158        AnsiDecoder {
159            style: Style::new(),
160        }
161    }
162
163    /// Decode a multi-line terminal string into one [`Text`] per line.
164    pub fn decode(&mut self, terminal_text: &str) -> Vec<Text> {
165        // `str::lines` matches Python's `splitlines` for `\n`/`\r\n` endings.
166        terminal_text.lines().map(|l| self.decode_line(l)).collect()
167    }
168
169    /// Decode as upstream 15.0.0's `AnsiDecoder.decode` splits:
170    /// `re.split(r"(?<=\n)", text)` then `line.rstrip("\n")`. Only `\n` ends
171    /// a line (a `\r` before it is kept, so it resets the line), and text
172    /// ending in a newline, or empty text, yields a final empty line.
173    /// [`decode`](Self::decode) keeps the older `splitlines` behaviour that
174    /// existing callers rely on.
175    pub fn decode_split_newlines(&mut self, terminal_text: &str) -> Vec<Text> {
176        terminal_text
177            .split_inclusive('\n')
178            .chain((terminal_text.is_empty() || terminal_text.ends_with('\n')).then_some(""))
179            .map(|line| self.decode_line(line.trim_end_matches('\n')))
180            .collect()
181    }
182
183    /// Decode a single line containing ANSI codes.
184    pub fn decode_line(&mut self, line: &str) -> Text {
185        // A carriage return resets the line: keep only what follows the last one.
186        let line = line.rsplit('\r').next().unwrap_or(line);
187        let mut text = Text::new("");
188        for token in tokenize(line) {
189            match token {
190                Token::Plain(plain) => {
191                    let style = if self.style.is_null() {
192                        None
193                    } else {
194                        Some(self.style.clone().into())
195                    };
196                    text.append(&plain, style);
197                }
198                Token::Sgr(params) => self.apply_sgr(&params),
199                Token::Osc(osc) => self.apply_osc(&osc),
200            }
201        }
202        text
203    }
204
205    /// Apply an OSC body. Only hyperlinks (`8;<params>;<url>`) are meaningful:
206    /// the params (e.g. `id=…`) are ignored, and the URL is attached to — or,
207    /// when empty, cleared from — the running style. Port of the OSC branch of
208    /// `decode_line`.
209    fn apply_osc(&mut self, osc: &str) {
210        if let Some(rest) = osc.strip_prefix("8;") {
211            // partition on the first ';': everything after it is the link.
212            if let Some(idx) = rest.find(';') {
213                let link = &rest[idx + 1..];
214                let link = (!link.is_empty()).then(|| link.to_string());
215                self.style = self.style.update_link(link);
216            }
217        }
218    }
219
220    /// Apply an SGR parameter string (e.g. `"1;31"`) to the running style.
221    fn apply_sgr(&mut self, params: &str) {
222        // Lenient parse: keep digit runs (clamped to 255) and empty fields (0).
223        let codes: Vec<u16> = params
224            .split(';')
225            .filter(|c| c.is_empty() || c.bytes().all(|b| b.is_ascii_digit()))
226            .map(|c| c.parse::<u32>().unwrap_or(0).min(255) as u16)
227            .collect();
228
229        let mut iter = codes.into_iter();
230        while let Some(code) = iter.next() {
231            if code == 0 {
232                self.style = Style::new();
233            } else if let Some(spec) = sgr_style(code) {
234                if let Ok(parsed) = Style::parse(spec) {
235                    self.style = self.style.combine(&parsed);
236                }
237            } else if code == 38 {
238                if let Some(color) = read_extended_color(&mut iter) {
239                    self.style = self.style.combine(&Style::from_color(Some(color), None));
240                }
241            } else if code == 48 {
242                if let Some(color) = read_extended_color(&mut iter) {
243                    self.style = self.style.combine(&Style::from_color(None, Some(color)));
244                }
245            }
246        }
247    }
248}
249
250/// Read the color following a `38`/`48` code: `5;<n>` (8-bit) or `2;<r>;<g>;<b>`
251/// (truecolor). Returns `None` if the sequence is truncated (lenient, like
252/// upstream's `suppress(StopIteration)`).
253fn read_extended_color(iter: &mut impl Iterator<Item = u16>) -> Option<Color> {
254    match iter.next()? {
255        5 => Some(Color::from_ansi(iter.next()? as u8)),
256        2 => {
257            let r = iter.next()? as u8;
258            let g = iter.next()? as u8;
259            let b = iter.next()? as u8;
260            Some(Color::from_rgb(r, g, b))
261        }
262        _ => None,
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::color::ColorSystem;
270    use crate::console::Console;
271
272    fn round_trip(input: &str) -> String {
273        let mut decoder = AnsiDecoder::new();
274        let console = Console::builder()
275            .force_terminal(true)
276            .color_system(Some(ColorSystem::Truecolor))
277            .width(80)
278            .build();
279        decoder
280            .decode(input)
281            .iter()
282            .map(|t| console.render_to_string(t))
283            .collect::<Vec<_>>()
284            .join("\n")
285    }
286
287    #[test]
288    fn plain_text_has_no_style() {
289        assert_eq!(round_trip("hello"), "hello");
290    }
291
292    #[test]
293    fn bold_red_round_trips() {
294        // Both the input and the re-render use rich's own SGR ordering.
295        assert_eq!(round_trip("\x1b[1;31mhi\x1b[0m"), "\x1b[1;31mhi\x1b[0m");
296    }
297
298    #[test]
299    fn eight_bit_and_truecolor() {
300        assert_eq!(
301            round_trip("\x1b[38;5;214mx\x1b[0m"),
302            "\x1b[38;5;214mx\x1b[0m"
303        );
304        assert_eq!(
305            round_trip("\x1b[38;2;255;136;0mx\x1b[0m"),
306            "\x1b[38;2;255;136;0mx\x1b[0m"
307        );
308    }
309
310    #[test]
311    fn style_persists_until_reset() {
312        // "a" is bold; without a reset, "b" on the next segment stays bold.
313        assert_eq!(round_trip("\x1b[1mab"), "\x1b[1mab\x1b[0m");
314    }
315
316    #[test]
317    fn non_sgr_csi_is_dropped() {
318        assert_eq!(round_trip("\x1b[2Jhi"), "hi");
319    }
320
321    #[test]
322    fn osc8_hyperlink_round_trips() {
323        // A styled hyperlink: the URL attaches to the running style, so the
324        // re-render wraps the styled text in OSC 8. Matches real rich 15.0.0
325        // except upstream's random `id=` field, which we omit (DIVERGENCES #20).
326        assert_eq!(
327            round_trip("\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"),
328            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"
329        );
330    }
331
332    #[test]
333    fn osc8_link_without_style_and_clear() {
334        // Unstyled link text between plain runs; the empty closing OSC clears the
335        // link so " after" is plain.
336        assert_eq!(
337            round_trip("before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"),
338            "before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"
339        );
340    }
341
342    #[test]
343    fn osc8_id_param_is_ignored() {
344        // Upstream includes a random `id=`; when decoding we drop the params and
345        // keep only the URL, re-emitting without an id.
346        assert_eq!(
347            round_trip("\x1b]8;id=42;https://x.io\x1b\\a\x1b]8;;\x1b\\"),
348            "\x1b]8;;https://x.io\x1b\\a\x1b]8;;\x1b\\"
349        );
350    }
351}