Skip to main content

rich/
markup.rs

1//! Console markup parsing.
2//!
3//! Port of upstream `rich/markup.py`. Turns `"[bold]Hello[/] [red]World[/]"`
4//! into a [`Text`] with spans. Tags are resolved against a [`Theme`] first (so
5//! named styles like `[warning]` work) and otherwise parsed as inline style
6//! definitions via [`Style::parse`].
7//!
8//! A `[…]` is only a tag when it starts with `[a-z#/@]` (matching upstream's
9//! `RE_TAGS`), so `[Hello]` and `[42]` are literal text. `\[` escapes a bracket,
10//! and an unmatched closing tag raises [`RichError::Markup`](crate::RichError).
11//! `@`-tags (meta/handlers) apply no visible style.
12
13use fancy_regex::Regex;
14
15use crate::errors::{Result, RichError};
16use crate::style::{Style, StyleType};
17use crate::text::{Span, Text};
18
19struct RawSpan {
20    start: usize,
21    end: usize,
22    /// The tag's normalized name, e.g. `bold` for `[b]`.
23    name: String,
24    /// Anything after the first `=`, e.g. the URL of `[link=https://…]`.
25    parameters: Option<String>,
26}
27
28/// Whether `c` may start a markup tag (the `[a-z#/@]` class in `RE_TAGS`).
29/// Used by [`escape`], which decides where to insert backslashes and so needs
30/// the same notion of "this bracket would open a tag".
31fn is_tag_start(c: char) -> bool {
32    c.is_ascii_lowercase() || c == '#' || c == '/' || c == '@'
33}
34
35/// Upstream's `RE_TAGS`, verbatim.
36///
37/// Using the same expression rather than hand-scanning is deliberate. Two of its
38/// details are easy to get wrong by hand and both were wrong here before:
39///
40/// - `(\\*)` captures the **whole run** of preceding backslashes, so escaping
41///   can be decided by parity. An even-length run is *not* an escape: it emits
42///   half as many literal backslashes and the tag still fires.
43/// - `[^\[]*?` forbids a `[` inside the tag body, so `[a[b]` is not a tag at
44///   all — it is literal text, and scanning resumes at the inner `[`.
45static RE_TAGS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
46    Regex::new(r"((\\*)\[([a-z#/@][^\[]*?)\])").expect("RE_TAGS is a valid pattern")
47});
48
49/// One item from the scanner: either literal text or a tag.
50enum Event<'a> {
51    Text(String),
52    Tag {
53        name: &'a str,
54        parameters: Option<String>,
55        /// Byte offset in the source markup, used only for error messages.
56        position: usize,
57    },
58}
59
60/// Split `markup` into text and tag events. Direct port of `markup._parse`.
61fn parse(markup: &str) -> Result<Vec<Event<'_>>> {
62    let mut events: Vec<Event> = Vec::new();
63    let mut position = 0usize;
64
65    for captures in RE_TAGS.captures_iter(markup) {
66        let captures =
67            captures.map_err(|e| RichError::Markup(format!("markup scan failed: {e}")))?;
68        let whole = captures.get(1).expect("group 1 always participates");
69        let escapes = captures.get(2).map_or("", |m| m.as_str());
70        let tag_text = captures
71            .get(3)
72            .expect("group 3 always participates")
73            .as_str();
74        let (mut start, end) = (whole.start(), whole.end());
75
76        if start > position {
77            events.push(Event::Text(unescape_brackets(&markup[position..start])));
78        }
79
80        if !escapes.is_empty() {
81            // `divmod(len(escapes), 2)`: pairs collapse to one literal
82            // backslash each, and only an odd remainder escapes the tag.
83            let (backslashes, escaped) = (escapes.len() / 2, escapes.len() % 2 == 1);
84            if backslashes > 0 {
85                events.push(Event::Text("\\".repeat(backslashes)));
86                start += backslashes * 2;
87            }
88            if escaped {
89                // The tag is escaped: emit it as literal text, minus its
90                // backslashes, and do not open anything.
91                events.push(Event::Text(whole.as_str()[escapes.len()..].to_string()));
92                position = end;
93                continue;
94            }
95        }
96
97        // Everything after the first `=` is the tag's parameters, not part of
98        // its name — so `[link=url]` closes with `[/link]`.
99        let (name, parameters) = match tag_text.split_once('=') {
100            Some((name, params)) => (name, Some(params.to_string())),
101            None => (tag_text, None),
102        };
103        events.push(Event::Tag {
104            name,
105            parameters,
106            position: start,
107        });
108        position = end;
109    }
110
111    if position < markup.len() {
112        events.push(Event::Text(unescape_brackets(&markup[position..])));
113    }
114    Ok(events)
115}
116
117/// `\[` in ordinary text is a literal bracket. Upstream applies exactly this
118/// replacement to every text run it yields.
119fn unescape_brackets(text: &str) -> String {
120    text.replace("\\[", "[")
121}
122
123/// Append text to the plain buffer, dropping control codes as it goes.
124///
125/// Stripping has to happen *here*, not later in `Text::new`: span offsets are
126/// computed against `plain` as it is built, so removing bytes afterwards shifts
127/// the text out from under them — which panicked on any markup containing a
128/// carriage return.
129fn push_plain(plain: &mut String, chunk: &str) {
130    if chunk.chars().any(crate::text::is_control_code) {
131        plain.extend(chunk.chars().filter(|c| !crate::text::is_control_code(*c)));
132    } else {
133        plain.push_str(chunk);
134    }
135}
136
137/// Escape `markup` so it renders literally (no tags interpreted). Port of
138/// `rich.markup.escape`: a `\` is inserted before every tag-opening `[`, any
139/// pre-existing backslashes are doubled, and a lone trailing `\` is doubled.
140pub fn escape(markup: &str) -> String {
141    let bytes = markup.as_bytes();
142    let mut out = String::with_capacity(markup.len() + 2);
143    let mut i = 0;
144    while i < bytes.len() {
145        // Count a run of backslashes.
146        let run_start = i;
147        while i < bytes.len() && bytes[i] == b'\\' {
148            i += 1;
149        }
150        let backslashes = i - run_start;
151        // A tag opener is `[` followed by a tag-start char.
152        let is_opener =
153            bytes.get(i) == Some(&b'[') && markup[i + 1..].chars().next().is_some_and(is_tag_start);
154        if is_opener {
155            // Double the run and add one more before the bracket.
156            for _ in 0..backslashes * 2 + 1 {
157                out.push('\\');
158            }
159            out.push('[');
160            i += 1;
161        } else {
162            for _ in 0..backslashes {
163                out.push('\\');
164            }
165            if let Some(c) = markup[i..].chars().next() {
166                out.push(c);
167                i += c.len_utf8();
168            }
169        }
170    }
171    // A single trailing backslash is doubled so it can't escape appended text.
172    if out.ends_with('\\') && !out.ends_with("\\\\") {
173        out.push('\\');
174    }
175    out
176}
177
178/// Parse `markup` into styled [`Text`].
179///
180/// Tag names are stored unresolved on the spans, so the returned `Text` renders
181/// in whichever theme prints it. The `Result` therefore reports only genuine
182/// *syntax* errors — an unmatched or mismatched closing tag. An unknown tag
183/// *name* is not an error: it renders as a no-op, as it does upstream.
184pub fn render(markup: &str) -> Result<Text> {
185    let mut plain = String::new();
186    let mut raw_spans: Vec<RawSpan> = Vec::new();
187    // Open tags, as `(normalized name, parameters, start offset)`. The name is
188    // normalized on the way in so that `[b]…[/bold]` matches, as upstream does.
189    let mut stack: Vec<(String, Option<String>, usize)> = Vec::new();
190
191    for event in parse(markup)? {
192        match event {
193            Event::Text(chunk) => push_plain(&mut plain, &chunk),
194            Event::Tag {
195                name: tag_name,
196                parameters,
197                position: i,
198            } => {
199                if let Some(name) = tag_name.strip_prefix('/') {
200                    let name = name.trim();
201                    let end = plain.len();
202                    let (open_name, open_parameters, start) = if name.is_empty() {
203                        // Auto-close: pop the most recent open tag, or error.
204                        stack.pop().ok_or_else(|| {
205                            RichError::Markup(format!(
206                                "closing tag '[/]' at position {i} has nothing to close"
207                            ))
208                        })?
209                    } else {
210                        // Explicit close. Both sides are normalized first, so
211                        // `[b]…[/bold]` matches — upstream normalizes the open
212                        // tag's name on push and the close name here.
213                        let wanted = Style::normalize(name);
214                        let pos = stack
215                            .iter()
216                            .rposition(|(open, _, _)| *open == wanted)
217                            .ok_or_else(|| {
218                                RichError::Markup(format!(
219                                "closing tag '[/{name}]' at position {i} doesn't match any open tag"
220                            ))
221                            })?;
222                        stack.remove(pos)
223                    };
224                    raw_spans.push(RawSpan {
225                        start,
226                        end,
227                        name: open_name,
228                        parameters: open_parameters,
229                    });
230                } else {
231                    stack.push((Style::normalize(tag_name), parameters, plain.len()));
232                }
233            }
234        }
235    }
236
237    // Auto-close anything still open (lenient — see module docs).
238    let end = plain.len();
239    while let Some((open_name, open_parameters, start)) = stack.pop() {
240        raw_spans.push(RawSpan {
241            start,
242            end,
243            name: open_name,
244            parameters: open_parameters,
245        });
246    }
247
248    // Carry tag strings through as names — the theme of whichever console
249    // renders this text resolves them. Resolving here instead would freeze the
250    // colours at parse time and make an unknown tag an error rather than the
251    // no-op upstream produces.
252    let mut spans: Vec<Span> = Vec::with_capacity(raw_spans.len());
253    for raw in raw_spans {
254        // Zero-length spans are NOT skipped. Upstream keeps them, and they
255        // still contribute a boundary point when segments are cut, so
256        // `[b]a[i][/i]b[/b]` emits two runs rather than one merged run. Same
257        // colours either way — different bytes.
258        // `@`-prefixed tags are meta/handler tags (spans of app data). We don't
259        // model those, so they carry no styling — stated explicitly rather than
260        // left to fall out of a failed parse.
261        let style = if raw.name.starts_with('@') {
262            StyleType::Style(Style::new())
263        } else {
264            // Upstream's `str(Tag)`: the name, or `"{name} {parameters}"`. That
265            // is what turns `[link=https://x]` into the style `link https://x`,
266            // which `Style::parse` then understands.
267            StyleType::Name(match &raw.parameters {
268                Some(parameters) => format!("{} {}", raw.name, parameters),
269                None => raw.name.clone(),
270            })
271        };
272        spans.push(Span {
273            start: raw.start,
274            end: raw.end,
275            style,
276        });
277    }
278    // Outer spans first, so inner (more nested) spans are combined last and win.
279    //
280    // Upstream is `sorted(spans[::-1], key=attrgetter("start"))`, and both halves
281    // matter. Spans are pushed in *closing* order, innermost first, so the
282    // reverse puts the outermost first; the sort then keys on `start` **only**,
283    // and being stable it preserves that reversal for ties. Sorting by
284    // `(start, end desc)` instead looks equivalent but is not: two tags covering
285    // the exact same range compare Equal, the innermost stays first, and the
286    // outer tag ends up winning. `[red][blue]x[/][/]` must render blue.
287    spans.reverse();
288    spans.sort_by_key(|span| span.start);
289
290    let mut text = Text::new(plain);
291    for span in spans {
292        text.push_span(span);
293    }
294    Ok(text)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::color::ColorSystem;
301    use crate::theme::Theme;
302
303    fn render_to_ansi(markup: &str) -> String {
304        let text = render(markup).unwrap();
305        let segments = text.render(&Theme::default_theme(), &Style::new());
306        segments
307            .iter()
308            .map(|s| {
309                s.style
310                    .clone()
311                    .unwrap_or_default()
312                    .render(&s.text, Some(ColorSystem::Truecolor))
313            })
314            .collect()
315    }
316
317    #[test]
318    fn simple_tags() {
319        assert_eq!(
320            render_to_ansi("[bold]Hello[/] [red]World[/]"),
321            "\x1b[1mHello\x1b[0m \x1b[31mWorld\x1b[0m"
322        );
323    }
324
325    /// A control code must never reach `plain`, because span offsets are
326    /// computed against it as it is built. Stripping later shifts the text out
327    /// from under the offsets, and a boundary landing inside a multi-byte
328    /// character panics on slicing.
329    ///
330    /// Regression: `rich --print` on any input containing a CR — i.e. any file
331    /// with CRLF line endings — panicked.
332    #[test]
333    fn control_codes_never_desynchronise_span_offsets() {
334        let text = render("a\r[red]\u{2593}x[/]").expect("valid markup");
335        assert_eq!(text.plain(), "a\u{2593}x");
336        for span in text.spans() {
337            assert!(
338                text.plain().is_char_boundary(span.start)
339                    && text.plain().is_char_boundary(span.end),
340                "span {span:?} does not land on char boundaries of {:?}",
341                text.plain()
342            );
343        }
344        // Rendering is what actually panicked, so exercise it.
345        let rendered = render_to_ansi("a\r[red]\u{2593}x[/]");
346        assert!(rendered.contains('\u{2593}'), "got {rendered:?}");
347    }
348
349    #[test]
350    fn nested_inner_wins() {
351        // outer red, inner blue -> the inner color should apply to 'x'
352        let out = render_to_ansi("[red]a[blue]x[/]b[/]");
353        assert_eq!(out, "\x1b[31ma\x1b[0m\x1b[34mx\x1b[0m\x1b[31mb\x1b[0m");
354    }
355
356    #[test]
357    fn escaped_bracket_is_literal() {
358        let text = render("\\[not a tag]").unwrap();
359        assert_eq!(text.plain(), "[not a tag]");
360    }
361
362    #[test]
363    fn theme_name_resolves() {
364        // An upstream style name resolves via the theme rather than being
365        // parsed as an inline definition: "repr.number" -> bold not-italic cyan.
366        // Captured from real rich 15.0.0: `[repr.number]7[/]`.
367        assert_eq!(render_to_ansi("[repr.number]7[/]"), "\x1b[1;36m7\x1b[0m");
368    }
369
370    #[test]
371    fn none_is_the_null_style() {
372        // Upstream's `Style.parse` special-cases a bare "none" (many
373        // DEFAULT_STYLES entries are exactly that); as a *word* inside a longer
374        // definition it is still an error.
375        assert!(Style::parse("none").unwrap().is_null());
376        assert!(Style::parse("").unwrap().is_null());
377        assert!(Style::parse("bold none").is_err());
378    }
379
380    #[test]
381    fn bracket_is_literal_unless_tag_start() {
382        // Upstream only treats `[a-z#/@]`-led brackets as tags.
383        assert_eq!(render("[Hello] world").unwrap().plain(), "[Hello] world");
384        assert_eq!(render("[42] x").unwrap().plain(), "[42] x");
385    }
386
387    #[test]
388    fn hex_tag_and_meta_tag() {
389        // `#` starts a tag; `@` tags carry no visible style.
390        assert_eq!(
391            render_to_ansi("[#ff0000]x[/]"),
392            "\x1b[38;2;255;0;0mx\x1b[0m"
393        );
394        assert_eq!(render_to_ansi("[@foo]y[/]"), "y");
395    }
396
397    #[test]
398    fn unmatched_closing_tags_error() {
399        assert!(render("a[/]b").is_err());
400        assert!(render("[bold]a[/red]").is_err());
401        assert!(render("x[/red]y").is_err());
402        // Unclosed *opening* tags are auto-closed, not an error.
403        assert!(render("[bold]hi").is_ok());
404    }
405
406    #[test]
407    fn escape_matches_upstream() {
408        assert_eq!(escape("[bold]"), "\\[bold]");
409        assert_eq!(escape("a[b]c"), "a\\[b]c");
410        assert_eq!(escape("back\\slash"), "back\\slash");
411        assert_eq!(escape("trailing\\"), "trailing\\\\");
412        assert_eq!(escape("[Hello]"), "[Hello]"); // not a tag → unchanged
413                                                  // Escaped markup round-trips to the literal text.
414        assert_eq!(render(&escape("[bold]")).unwrap().plain(), "[bold]");
415    }
416}