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 a single line containing ANSI codes.
170    pub fn decode_line(&mut self, line: &str) -> Text {
171        // A carriage return resets the line: keep only what follows the last one.
172        let line = line.rsplit('\r').next().unwrap_or(line);
173        let mut text = Text::new("");
174        for token in tokenize(line) {
175            match token {
176                Token::Plain(plain) => {
177                    let style = if self.style.is_null() {
178                        None
179                    } else {
180                        Some(self.style.clone().into())
181                    };
182                    text.append(&plain, style);
183                }
184                Token::Sgr(params) => self.apply_sgr(&params),
185                Token::Osc(osc) => self.apply_osc(&osc),
186            }
187        }
188        text
189    }
190
191    /// Apply an OSC body. Only hyperlinks (`8;<params>;<url>`) are meaningful:
192    /// the params (e.g. `id=…`) are ignored, and the URL is attached to — or,
193    /// when empty, cleared from — the running style. Port of the OSC branch of
194    /// `decode_line`.
195    fn apply_osc(&mut self, osc: &str) {
196        if let Some(rest) = osc.strip_prefix("8;") {
197            // partition on the first ';': everything after it is the link.
198            if let Some(idx) = rest.find(';') {
199                let link = &rest[idx + 1..];
200                let link = (!link.is_empty()).then(|| link.to_string());
201                self.style = self.style.update_link(link);
202            }
203        }
204    }
205
206    /// Apply an SGR parameter string (e.g. `"1;31"`) to the running style.
207    fn apply_sgr(&mut self, params: &str) {
208        // Lenient parse: keep digit runs (clamped to 255) and empty fields (0).
209        let codes: Vec<u16> = params
210            .split(';')
211            .filter(|c| c.is_empty() || c.bytes().all(|b| b.is_ascii_digit()))
212            .map(|c| c.parse::<u32>().unwrap_or(0).min(255) as u16)
213            .collect();
214
215        let mut iter = codes.into_iter();
216        while let Some(code) = iter.next() {
217            if code == 0 {
218                self.style = Style::new();
219            } else if let Some(spec) = sgr_style(code) {
220                if let Ok(parsed) = Style::parse(spec) {
221                    self.style = self.style.combine(&parsed);
222                }
223            } else if code == 38 {
224                if let Some(color) = read_extended_color(&mut iter) {
225                    self.style = self.style.combine(&Style::from_color(Some(color), None));
226                }
227            } else if code == 48 {
228                if let Some(color) = read_extended_color(&mut iter) {
229                    self.style = self.style.combine(&Style::from_color(None, Some(color)));
230                }
231            }
232        }
233    }
234}
235
236/// Read the color following a `38`/`48` code: `5;<n>` (8-bit) or `2;<r>;<g>;<b>`
237/// (truecolor). Returns `None` if the sequence is truncated (lenient, like
238/// upstream's `suppress(StopIteration)`).
239fn read_extended_color(iter: &mut impl Iterator<Item = u16>) -> Option<Color> {
240    match iter.next()? {
241        5 => Some(Color::from_ansi(iter.next()? as u8)),
242        2 => {
243            let r = iter.next()? as u8;
244            let g = iter.next()? as u8;
245            let b = iter.next()? as u8;
246            Some(Color::from_rgb(r, g, b))
247        }
248        _ => None,
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::color::ColorSystem;
256    use crate::console::Console;
257
258    fn round_trip(input: &str) -> String {
259        let mut decoder = AnsiDecoder::new();
260        let console = Console::builder()
261            .force_terminal(true)
262            .color_system(Some(ColorSystem::Truecolor))
263            .width(80)
264            .build();
265        decoder
266            .decode(input)
267            .iter()
268            .map(|t| console.render_to_string(t))
269            .collect::<Vec<_>>()
270            .join("\n")
271    }
272
273    #[test]
274    fn plain_text_has_no_style() {
275        assert_eq!(round_trip("hello"), "hello");
276    }
277
278    #[test]
279    fn bold_red_round_trips() {
280        // Both the input and the re-render use rich's own SGR ordering.
281        assert_eq!(round_trip("\x1b[1;31mhi\x1b[0m"), "\x1b[1;31mhi\x1b[0m");
282    }
283
284    #[test]
285    fn eight_bit_and_truecolor() {
286        assert_eq!(
287            round_trip("\x1b[38;5;214mx\x1b[0m"),
288            "\x1b[38;5;214mx\x1b[0m"
289        );
290        assert_eq!(
291            round_trip("\x1b[38;2;255;136;0mx\x1b[0m"),
292            "\x1b[38;2;255;136;0mx\x1b[0m"
293        );
294    }
295
296    #[test]
297    fn style_persists_until_reset() {
298        // "a" is bold; without a reset, "b" on the next segment stays bold.
299        assert_eq!(round_trip("\x1b[1mab"), "\x1b[1mab\x1b[0m");
300    }
301
302    #[test]
303    fn non_sgr_csi_is_dropped() {
304        assert_eq!(round_trip("\x1b[2Jhi"), "hi");
305    }
306
307    #[test]
308    fn osc8_hyperlink_round_trips() {
309        // A styled hyperlink: the URL attaches to the running style, so the
310        // re-render wraps the styled text in OSC 8. Matches real rich 15.0.0
311        // except upstream's random `id=` field, which we omit (DIVERGENCES #20).
312        assert_eq!(
313            round_trip("\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"),
314            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"
315        );
316    }
317
318    #[test]
319    fn osc8_link_without_style_and_clear() {
320        // Unstyled link text between plain runs; the empty closing OSC clears the
321        // link so " after" is plain.
322        assert_eq!(
323            round_trip("before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"),
324            "before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"
325        );
326    }
327
328    #[test]
329    fn osc8_id_param_is_ignored() {
330        // Upstream includes a random `id=`; when decoding we drop the params and
331        // keep only the URL, re-emitting without an id.
332        assert_eq!(
333            round_trip("\x1b]8;id=42;https://x.io\x1b\\a\x1b]8;;\x1b\\"),
334            "\x1b]8;;https://x.io\x1b\\a\x1b]8;;\x1b\\"
335        );
336    }
337}