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 crate::errors::{Result, RichError};
14use crate::style::{Meta, MetaValue, Style, StyleType};
15use crate::text::{Span, Text};
16
17struct RawSpan {
18    start: usize,
19    end: usize,
20    style: StyleType,
21}
22
23/// A tag in console markup. Port of `rich.markup.Tag`.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Tag {
26    /// The tag name, e.g. `bold`.
27    pub name: String,
28    /// Anything after the first `=`, e.g. the URL of `[link=https://…]`.
29    pub parameters: Option<String>,
30}
31
32impl Tag {
33    /// A tag named `name`, with optional `parameters`.
34    pub fn new(name: impl Into<String>, parameters: Option<String>) -> Self {
35        Tag {
36            name: name.into(),
37            parameters,
38        }
39    }
40
41    /// The tag as markup: `[name]` or `[name=parameters]`. Port of
42    /// `Tag.markup`.
43    pub fn markup(&self) -> String {
44        match &self.parameters {
45            None => format!("[{}]", self.name),
46            Some(parameters) => format!("[{}={}]", self.name, parameters),
47        }
48    }
49}
50
51/// `str(tag)`: the name, or `"{name} {parameters}"`.
52impl std::fmt::Display for Tag {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match &self.parameters {
55            None => f.write_str(&self.name),
56            Some(parameters) => write!(f, "{} {}", self.name, parameters),
57        }
58    }
59}
60
61/// Python's `repr()` of a string.
62pub(crate) fn py_repr_str(value: &str) -> String {
63    let quote = if value.contains('\'') && !value.contains('"') {
64        '"'
65    } else {
66        '\''
67    };
68    let mut out = String::with_capacity(value.len() + 2);
69    out.push(quote);
70    for ch in value.chars() {
71        match ch {
72            '\\' => out.push_str("\\\\"),
73            '\n' => out.push_str("\\n"),
74            '\r' => out.push_str("\\r"),
75            '\t' => out.push_str("\\t"),
76            ch if ch == quote => {
77                out.push('\\');
78                out.push(ch);
79            }
80            ch if (ch as u32) < 0x20 || ch as u32 == 0x7f => {
81                out.push_str(&format!("\\x{:02x}", ch as u32));
82            }
83            ch => out.push(ch),
84        }
85    }
86    out.push(quote);
87    out
88}
89
90/// Whether `c` may start a markup tag (the `[a-z#/@]` class in `RE_TAGS`).
91/// Used by [`escape`], which decides where to insert backslashes and so needs
92/// the same notion of "this bracket would open a tag".
93fn is_tag_start(c: char) -> bool {
94    c.is_ascii_lowercase() || c == '#' || c == '/' || c == '@'
95}
96
97/// Upstream's `RE_TAGS`, verbatim.
98///
99/// Using the same expression rather than hand-scanning is deliberate. Two of its
100/// details are easy to get wrong by hand and both were wrong here before:
101///
102/// - `(\\*)` captures the **whole run** of preceding backslashes, so escaping
103///   can be decided by parity. An even-length run is *not* an escape: it emits
104///   half as many literal backslashes and the tag still fires.
105/// - `[^\[]*?` forbids a `[` inside the tag body, so `[a[b]` is not a tag at
106///   all — it is literal text, and scanning resumes at the inner `[`.
107///
108/// The scan itself is [`tag_matches`], which finds the same matches without
109/// the regex engine; this expression is kept as its reference, and
110/// `tag_matches_are_re_tags` holds the two together.
111#[cfg(test)]
112static RE_TAGS: std::sync::LazyLock<fancy_regex::Regex> = std::sync::LazyLock::new(|| {
113    fancy_regex::Regex::new(r"((\\*)\[([a-z#/@][^\[]*?)\])").expect("RE_TAGS is a valid pattern")
114});
115
116/// One match of `RE_TAGS`, as byte offsets: group 1 (the whole match) is
117/// `start..end`, group 2 (the backslashes) `start..bracket`, and group 3
118/// (the tag) `bracket + 1..end - 1`.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120struct TagMatch {
121    start: usize,
122    bracket: usize,
123    end: usize,
124}
125
126/// The matches `RE_TAGS.finditer(markup)` finds, found by scanning for the
127/// brackets: a `[` followed by a tag-start character opens a tag that the
128/// first `]` closes, unless a `[` comes first; the backslashes right before
129/// the `[`, back to the end of the previous match, belong to the match.
130/// Leftmost-first, as the regex is: a match can only start at a backslash
131/// run that leads to its `[`, so the first `[` that opens a tag starts the
132/// next match. Two to three times cheaper than the regex engine on every
133/// markup string (docs/PORTING.md, "Performance notes").
134fn tag_matches(markup: &str) -> Vec<TagMatch> {
135    let bytes = markup.as_bytes();
136    let mut matches = Vec::new();
137    // Where the regex's next search would start: the end of the last match.
138    let mut searched = 0usize;
139    let mut at = 0usize;
140    while let Some(offset) = bytes[at..].iter().position(|&b| b == b'[') {
141        let bracket = at + offset;
142        at = bracket + 1;
143        if !bytes
144            .get(bracket + 1)
145            .is_some_and(|&b| is_tag_start(char::from(b)))
146        {
147            continue;
148        }
149        // `[^\[]*?\]`: the first `]` after the tag-start character, if no
150        // `[` comes before it.
151        let body = bracket + 2;
152        match bytes[body..].iter().position(|&b| b == b']' || b == b'[') {
153            Some(close) if bytes[body + close] == b']' => {
154                let end = body + close + 1;
155                let mut start = bracket;
156                while start > searched && bytes[start - 1] == b'\\' {
157                    start -= 1;
158                }
159                matches.push(TagMatch {
160                    start,
161                    bracket,
162                    end,
163                });
164                searched = end;
165                at = end;
166            }
167            // A `[` first: that one may open a tag instead.
168            Some(_) => {}
169            // Neither: no `]` is left to close any tag.
170            None => break,
171        }
172    }
173    matches
174}
175
176/// One item from the scanner: either literal text or a tag.
177enum Event<'a> {
178    Text(std::borrow::Cow<'a, str>),
179    Tag {
180        name: &'a str,
181        parameters: Option<String>,
182        /// Byte offset in the source markup, used only for error messages.
183        position: usize,
184    },
185}
186
187/// Split `markup` into text and tag events. Direct port of `markup._parse`.
188fn parse(markup: &str) -> Result<Vec<Event<'_>>> {
189    let mut events: Vec<Event> = Vec::new();
190    let mut position = 0usize;
191
192    for found in tag_matches(markup) {
193        let whole = &markup[found.start..found.end];
194        let escapes = &markup[found.start..found.bracket];
195        let tag_text = &markup[found.bracket + 1..found.end - 1];
196        let (mut start, end) = (found.start, found.end);
197
198        if start > position {
199            events.push(Event::Text(unescape_brackets(&markup[position..start])));
200        }
201
202        if !escapes.is_empty() {
203            // `divmod(len(escapes), 2)`: pairs collapse to one literal
204            // backslash each, and only an odd remainder escapes the tag.
205            let (backslashes, escaped) = (escapes.len() / 2, escapes.len() % 2 == 1);
206            if backslashes > 0 {
207                events.push(Event::Text("\\".repeat(backslashes).into()));
208                start += backslashes * 2;
209            }
210            if escaped {
211                // The tag is escaped: emit it as literal text, minus its
212                // backslashes, and do not open anything.
213                events.push(Event::Text(whole[escapes.len()..].into()));
214                position = end;
215                continue;
216            }
217        }
218
219        // Everything after the first `=` is the tag's parameters, not part of
220        // its name — so `[link=url]` closes with `[/link]`.
221        let (name, parameters) = match tag_text.split_once('=') {
222            Some((name, params)) => (name, Some(params.to_string())),
223            None => (tag_text, None),
224        };
225        events.push(Event::Tag {
226            name,
227            parameters,
228            position: start,
229        });
230        position = end;
231    }
232
233    if position < markup.len() {
234        events.push(Event::Text(unescape_brackets(&markup[position..])));
235    }
236    Ok(events)
237}
238
239/// `\[` in ordinary text is a literal bracket. Upstream applies exactly this
240/// replacement to every text run it yields.
241fn unescape_brackets(text: &str) -> std::borrow::Cow<'_, str> {
242    // Without a backslash there is nothing to replace: borrow it, without a
243    // copy or the substring search (docs/PORTING.md, "Performance notes").
244    if !text.contains('\\') {
245        return text.into();
246    }
247    text.replace("\\[", "[").into()
248}
249
250/// Append text to the plain buffer, dropping control codes as it goes.
251///
252/// Stripping has to happen *here*, not later in `Text::new`: span offsets are
253/// computed against `plain` as it is built, so removing bytes afterwards shifts
254/// the text out from under them — which panicked on any markup containing a
255/// carriage return.
256fn push_plain(plain: &mut String, chunk: &str) {
257    if crate::text::has_control_code(chunk) {
258        plain.extend(chunk.chars().filter(|c| !crate::text::is_control_code(*c)));
259    } else {
260        plain.push_str(chunk);
261    }
262}
263
264/// Escape `markup` so it renders literally (no tags interpreted). Port of
265/// `rich.markup.escape`: a `\` is inserted before every tag-opening `[`, any
266/// pre-existing backslashes are doubled, and a lone trailing `\` is doubled.
267pub fn escape(markup: &str) -> String {
268    let bytes = markup.as_bytes();
269    let mut out = String::with_capacity(markup.len() + 2);
270    let mut i = 0;
271    while i < bytes.len() {
272        // Count a run of backslashes.
273        let run_start = i;
274        while i < bytes.len() && bytes[i] == b'\\' {
275            i += 1;
276        }
277        let backslashes = i - run_start;
278        // A tag opener is `[` followed by a tag-start char.
279        let is_opener =
280            bytes.get(i) == Some(&b'[') && markup[i + 1..].chars().next().is_some_and(is_tag_start);
281        if is_opener {
282            // Double the run and add one more before the bracket.
283            for _ in 0..backslashes * 2 + 1 {
284                out.push('\\');
285            }
286            out.push('[');
287            i += 1;
288        } else {
289            for _ in 0..backslashes {
290                out.push('\\');
291            }
292            if let Some(c) = markup[i..].chars().next() {
293                out.push(c);
294                i += c.len_utf8();
295            }
296        }
297    }
298    // A single trailing backslash is doubled so it can't escape appended text.
299    if out.ends_with('\\') && !out.ends_with("\\\\") {
300        out.push('\\');
301    }
302    out
303}
304
305/// Parse `markup` into styled [`Text`].
306///
307/// Tag names are stored unresolved on the spans, so the returned `Text` renders
308/// in whichever theme prints it. The `Result` therefore reports only genuine
309/// *syntax* errors — an unmatched or mismatched closing tag. An unknown tag
310/// *name* is not an error: it renders as a no-op, as it does upstream.
311pub fn render(markup: &str) -> Result<Text> {
312    render_inner(markup, false, &StyleType::default())
313}
314
315/// [`render`] with a base `style` for the whole text and, when `emoji` is
316/// set, `:emoji:` codes replaced. Port of `markup.render(markup, style,
317/// emoji, emoji_variant)`: a string with no `[` is replaced whole, with
318/// `emoji_variant`, and not parsed.
319pub fn render_with(
320    markup: &str,
321    style: impl Into<StyleType>,
322    emoji: bool,
323    emoji_variant: Option<crate::emoji::EmojiVariant>,
324) -> Result<Text> {
325    let style = style.into();
326    if !markup.contains('[') {
327        let plain = if emoji {
328            crate::emoji::replace_with_variant(markup, emoji_variant)
329        } else {
330            markup.to_string()
331        };
332        return Ok(Text::styled(plain, style));
333    }
334    render_inner(markup, emoji, &style)
335}
336
337/// [`render`] with `:emoji:` codes replaced, as upstream's
338/// `markup.render(emoji=True)` does: only in the text *between* tags, one
339/// chunk at a time, so tag names and parameters are left alone and a
340/// [`MarkupError`](RichError::Markup) position counts characters of the
341/// original string, not of the emoji-replaced one.
342///
343/// Callers handle a string with no `[` themselves: upstream replaces it whole,
344/// with the console's default variant, instead of parsing it.
345pub fn render_emoji(markup: &str) -> Result<Text> {
346    render_inner(markup, true, &StyleType::default())
347}
348
349/// `RE_HANDLER`: `^([\w.]*?)(\(.*?\))?$`, as `(handler name, parameters)`.
350fn match_handler(parameters: &str) -> Option<(&str, Option<&str>)> {
351    let is_name = |c: char| c.is_alphanumeric() || c == '_' || c == '.';
352    // The lazy name stops at the first `(` that starts a group running to the
353    // end; without one, the whole string must be a name.
354    if parameters.chars().all(is_name) {
355        return Some((parameters, None));
356    }
357    let open = parameters.find(|c: char| !is_name(c))?;
358    let (name, rest) = parameters.split_at(open);
359    // `\(.*?\)` then `$`: the group must start with `(` and end at the last
360    // character with `)` (`.` does not match a newline).
361    if rest.starts_with('(') && rest.ends_with(')') && rest.len() >= 2 && !rest.contains('\n') {
362        return Some((name, Some(rest)));
363    }
364    None
365}
366
367/// The meta of a closed `@` tag. Port of the `@` branch of `markup.render`:
368/// `{name: ()}` with no parameters, `{name: (handler, args)}` for a handler
369/// call like `foo(1, 'x')`, otherwise `{name: literal_eval(parameters)}`.
370fn handler_meta(name: &str, parameters: Option<&str>) -> Result<Meta> {
371    let mut meta = Meta::new();
372    let Some(raw) = parameters.filter(|parameters| !parameters.is_empty()) else {
373        meta.insert(name, MetaValue::Tuple(Vec::new()));
374        return Ok(meta);
375    };
376    let mut parameters = raw.trim();
377    let mut handler_name = "";
378    if let Some((handler, arguments)) = match_handler(parameters) {
379        handler_name = handler;
380        parameters = arguments.unwrap_or("()");
381    }
382    let value = literal_eval(parameters).map_err(|error| {
383        RichError::Markup(match error {
384            LiteralError::Syntax(message) => format!(
385                "error parsing {} in {}; {message}",
386                py_repr_str(parameters),
387                py_repr_str(raw)
388            ),
389            LiteralError::Value(message) => {
390                format!("error parsing {}; {message}", py_repr_str(raw))
391            }
392        })
393    })?;
394    let value = if handler_name.is_empty() {
395        value
396    } else {
397        let arguments = match value {
398            MetaValue::Tuple(items) => MetaValue::Tuple(items),
399            other => MetaValue::Tuple(vec![other]),
400        };
401        MetaValue::Tuple(vec![MetaValue::Str(handler_name.to_string()), arguments])
402    };
403    meta.insert(name, value);
404    Ok(meta)
405}
406
407/// Why [`literal_eval`] failed: upstream's `SyntaxError` (with its message)
408/// or any other exception (`ValueError` for a name or an operator).
409#[derive(Debug, PartialEq)]
410enum LiteralError {
411    Syntax(String),
412    Value(String),
413}
414
415/// Python's `ast.literal_eval` for the values a style's meta can hold:
416/// numbers (with one sign), strings, `True`, `False`, `None`, tuples and
417/// lists. Anything else is a `ValueError` (upstream's message names an AST
418/// node at a memory address, which this one leaves out).
419fn literal_eval(source: &str) -> std::result::Result<MetaValue, LiteralError> {
420    tokenize_check(source)?;
421    let mut parser = LiteralParser {
422        chars: source
423            .trim_matches(|c| c == ' ' || c == '\t')
424            .chars()
425            .collect(),
426        position: 0,
427    };
428    let value = parser.expression()?;
429    parser.skip_space();
430    if parser.position < parser.chars.len() {
431        return Err(parser.trailing());
432    }
433    Ok(value)
434}
435
436/// The errors Python's tokenizer raises before any parsing: an unterminated
437/// string, and unbalanced or mismatched brackets.
438fn tokenize_check(source: &str) -> std::result::Result<(), LiteralError> {
439    let syntax = |message: String| Err(LiteralError::Syntax(message));
440    let mut open: Vec<char> = Vec::new();
441    let mut chars = source.chars();
442    while let Some(c) = chars.next() {
443        match c {
444            '\'' | '"' => loop {
445                match chars.next() {
446                    None => {
447                        return syntax(
448                            "unterminated string literal (detected at line 1)".to_string(),
449                        )
450                    }
451                    Some('\\') => {
452                        chars.next();
453                    }
454                    Some(end) if end == c => break,
455                    Some(_) => {}
456                }
457            },
458            '(' | '[' | '{' => open.push(c),
459            ')' | ']' | '}' => match open.pop() {
460                None => return syntax(format!("unmatched '{c}'")),
461                Some(opener) => {
462                    let expected = match opener {
463                        '(' => ')',
464                        '[' => ']',
465                        _ => '}',
466                    };
467                    if c != expected {
468                        return syntax(format!(
469                            "closing parenthesis '{c}' does not match opening parenthesis '{opener}'"
470                        ));
471                    }
472                }
473            },
474            _ => {}
475        }
476    }
477    match open.last() {
478        Some(opener) => syntax(format!("'{opener}' was never closed")),
479        None => Ok(()),
480    }
481}
482
483struct LiteralParser {
484    chars: Vec<char>,
485    position: usize,
486}
487
488impl LiteralParser {
489    fn peek(&self) -> Option<char> {
490        self.chars.get(self.position).copied()
491    }
492
493    fn skip_space(&mut self) {
494        while matches!(self.peek(), Some(' ' | '\t')) {
495            self.position += 1;
496        }
497    }
498
499    fn malformed() -> LiteralError {
500        LiteralError::Value("malformed node or string".to_string())
501    }
502
503    /// Something follows a complete value: a name or operator is valid
504    /// Python but not a literal; anything else is a syntax error.
505    fn trailing(&self) -> LiteralError {
506        match self.peek() {
507            Some(c) if "+-*/%&|^<>=.@[(".contains(c) => Self::malformed(),
508            Some(')') => LiteralError::Syntax("unmatched ')'".to_string()),
509            Some(']') => LiteralError::Syntax("unmatched ']'".to_string()),
510            _ => LiteralError::Syntax("invalid syntax".to_string()),
511        }
512    }
513
514    fn expression(&mut self) -> std::result::Result<MetaValue, LiteralError> {
515        self.skip_space();
516        let Some(c) = self.peek() else {
517            return Err(LiteralError::Syntax("invalid syntax".to_string()));
518        };
519        match c {
520            '(' | '[' => self.sequence(c),
521            '\'' | '"' => {
522                let mut value = self.string()?;
523                // Adjacent string literals concatenate.
524                loop {
525                    self.skip_space();
526                    match self.peek() {
527                        Some('\'' | '"') => value.push_str(&self.string()?),
528                        _ => break,
529                    }
530                }
531                Ok(MetaValue::Str(value))
532            }
533            '+' | '-' => {
534                self.position += 1;
535                self.skip_space();
536                match self.peek() {
537                    Some(d) if d.is_ascii_digit() || d == '.' => match self.number()? {
538                        MetaValue::Int(value) if c == '-' => Ok(MetaValue::Int(-value)),
539                        MetaValue::Float(value) if c == '-' => Ok(MetaValue::Float(-value)),
540                        value => Ok(value),
541                    },
542                    _ => Err(Self::malformed()),
543                }
544            }
545            c if c.is_ascii_digit() || c == '.' => self.number(),
546            c if c.is_alphabetic() || c == '_' => {
547                let start = self.position;
548                while self.peek().is_some_and(|c| c.is_alphanumeric() || c == '_') {
549                    self.position += 1;
550                }
551                let word: String = self.chars[start..self.position].iter().collect();
552                match word.as_str() {
553                    "True" => Ok(MetaValue::Bool(true)),
554                    "False" => Ok(MetaValue::Bool(false)),
555                    "None" => Ok(MetaValue::None),
556                    _ => Err(Self::malformed()),
557                }
558            }
559            '{' => Err(Self::malformed()),
560            _ => Err(LiteralError::Syntax("invalid syntax".to_string())),
561        }
562    }
563
564    fn sequence(&mut self, open: char) -> std::result::Result<MetaValue, LiteralError> {
565        let close = if open == '(' { ')' } else { ']' };
566        self.position += 1;
567        let mut items = Vec::new();
568        let mut trailing_comma = false;
569        loop {
570            self.skip_space();
571            match self.peek() {
572                None => return Err(LiteralError::Syntax(format!("'{open}' was never closed"))),
573                Some(c) if c == close => {
574                    self.position += 1;
575                    break;
576                }
577                _ => {}
578            }
579            items.push(self.expression()?);
580            self.skip_space();
581            match self.peek() {
582                Some(',') => {
583                    self.position += 1;
584                    trailing_comma = true;
585                }
586                Some(c) if c == close => {
587                    trailing_comma = false;
588                }
589                None => return Err(LiteralError::Syntax(format!("'{open}' was never closed"))),
590                Some(_) => return Err(self.trailing()),
591            }
592        }
593        Ok(if open == '[' {
594            MetaValue::List(items)
595        } else if items.len() == 1 && !trailing_comma {
596            // `(x)` is just `x`.
597            items.pop().expect("one item")
598        } else {
599            MetaValue::Tuple(items)
600        })
601    }
602
603    fn string(&mut self) -> std::result::Result<String, LiteralError> {
604        let quote = self.peek().expect("a quote");
605        self.position += 1;
606        let mut value = String::new();
607        loop {
608            let Some(c) = self.peek() else {
609                return Err(LiteralError::Syntax(
610                    "unterminated string literal (detected at line 1)".to_string(),
611                ));
612            };
613            self.position += 1;
614            if c == quote {
615                return Ok(value);
616            }
617            if c != '\\' {
618                value.push(c);
619                continue;
620            }
621            let Some(escaped) = self.peek() else {
622                return Err(LiteralError::Syntax(
623                    "unterminated string literal (detected at line 1)".to_string(),
624                ));
625            };
626            self.position += 1;
627            match escaped {
628                'n' => value.push('\n'),
629                't' => value.push('\t'),
630                'r' => value.push('\r'),
631                '0' => value.push('\0'),
632                '\\' | '\'' | '"' => value.push(escaped),
633                other => {
634                    value.push('\\');
635                    value.push(other);
636                }
637            }
638        }
639    }
640
641    fn number(&mut self) -> std::result::Result<MetaValue, LiteralError> {
642        let start = self.position;
643        while self
644            .peek()
645            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
646        {
647            let c = self.peek().expect("checked");
648            self.position += 1;
649            // An exponent's sign belongs to the number.
650            if matches!(c, 'e' | 'E') && matches!(self.peek(), Some('+' | '-')) {
651                let radix_prefix = self.chars[start..].starts_with(&['0', 'x'])
652                    || self.chars[start..].starts_with(&['0', 'X']);
653                if !radix_prefix {
654                    self.position += 1;
655                }
656            }
657        }
658        let word: String = self.chars[start..self.position]
659            .iter()
660            .filter(|&&c| c != '_')
661            .collect();
662        let invalid = || LiteralError::Syntax("invalid syntax".to_string());
663        let lower = word.to_ascii_lowercase();
664        for (prefix, radix) in [("0x", 16), ("0o", 8), ("0b", 2)] {
665            if let Some(digits) = lower.strip_prefix(prefix) {
666                return i64::from_str_radix(digits, radix)
667                    .map(MetaValue::Int)
668                    .map_err(|_| invalid());
669            }
670        }
671        if word.chars().all(|c| c.is_ascii_digit()) {
672            // Python rejects a leading zero on a non-zero decimal integer.
673            if word.len() > 1 && word.starts_with('0') && word.chars().any(|c| c != '0') {
674                return Err(invalid());
675            }
676            return word.parse().map(MetaValue::Int).map_err(|_| invalid());
677        }
678        word.parse::<f64>()
679            .ok()
680            .filter(|_| !lower.contains("inf") && !lower.contains("nan"))
681            .map(MetaValue::Float)
682            .ok_or_else(invalid)
683    }
684}
685
686fn render_inner(markup: &str, emoji: bool, base_style: &StyleType) -> Result<Text> {
687    // Never longer than the markup but for emoji, which it mostly is not.
688    let mut plain = String::with_capacity(markup.len());
689    let mut raw_spans: Vec<RawSpan> = Vec::new();
690    // Open tags, as `(normalized name, parameters, start offset)`. The name is
691    // normalized on the way in so that `[b]…[/bold]` matches, as upstream does.
692    let mut stack: Vec<(String, Option<String>, usize)> = Vec::new();
693
694    for event in parse(markup)? {
695        match event {
696            // Every emoji code is between colons: text without one is
697            // pushed as it is, without the emoji scan's copy.
698            Event::Text(chunk) if emoji && chunk.contains(':') => {
699                push_plain(&mut plain, &crate::emoji::replace(&chunk))
700            }
701            Event::Text(chunk) => push_plain(&mut plain, &chunk),
702            Event::Tag {
703                name: tag_name,
704                parameters,
705                position,
706            } => {
707                // Upstream reports the position in characters (a Python
708                // string index), not bytes. Counted only for an error: doing
709                // it per tag would make long markup quadratic.
710                let at = || markup[..position].chars().count();
711                if let Some(name) = tag_name.strip_prefix('/') {
712                    let name = name.trim();
713                    let end = plain.len();
714                    // `tag.markup` of the closing tag, as written: built only
715                    // for the error that quotes it.
716                    let closing = || Tag::new(tag_name, parameters.clone()).markup();
717                    let (open_name, open_parameters, start) = if name.is_empty() {
718                        // Auto-close: pop the most recent open tag, or error.
719                        stack.pop().ok_or_else(|| {
720                            RichError::Markup(format!(
721                                "closing tag '[/]' at position {} has nothing to close",
722                                at()
723                            ))
724                        })?
725                    } else {
726                        // Explicit close. Both sides are normalized first, so
727                        // `[b]…[/bold]` matches — upstream normalizes the open
728                        // tag's name on push and the close name here.
729                        let wanted = Style::normalize(name);
730                        let pos = stack
731                            .iter()
732                            .rposition(|(open, _, _)| *open == wanted)
733                            .ok_or_else(|| {
734                                RichError::Markup(format!(
735                                    "closing tag '{}' at position {} doesn't match any open tag",
736                                    closing(),
737                                    at()
738                                ))
739                            })?;
740                        stack.remove(pos)
741                    };
742                    let style = if open_name.starts_with('@') {
743                        StyleType::Style(Style::from_meta(handler_meta(
744                            &open_name,
745                            open_parameters.as_deref(),
746                        )?))
747                    } else {
748                        StyleType::Name(Tag::new(open_name, open_parameters).to_string())
749                    };
750                    raw_spans.push(RawSpan { start, end, style });
751                } else {
752                    stack.push((Style::normalize(tag_name), parameters, plain.len()));
753                }
754            }
755        }
756    }
757
758    // Auto-close anything still open (lenient — see module docs).
759    let end = plain.len();
760    // Upstream gives these the tag string as a style name, `@` tags included:
761    // the name resolves to no style when rendered.
762    while let Some((open_name, open_parameters, start)) = stack.pop() {
763        raw_spans.push(RawSpan {
764            start,
765            end,
766            style: StyleType::Name(Tag::new(open_name, open_parameters).to_string()),
767        });
768    }
769
770    // Tag strings are carried through as names: the theme of whichever
771    // console renders this text resolves them. Resolving here instead would
772    // freeze the colours at parse time and make an unknown tag an error
773    // rather than the no-op upstream produces. Upstream's `str(Tag)` turns
774    // `[link=https://x]` into the style `link https://x`.
775    //
776    // Zero-length spans are NOT skipped. Upstream keeps them, and they still
777    // contribute a boundary point when segments are cut, so `[b]a[i][/i]b[/b]`
778    // emits two runs rather than one merged run.
779    let mut spans: Vec<Span> = raw_spans
780        .into_iter()
781        .map(|raw| Span {
782            start: raw.start,
783            end: raw.end,
784            style: raw.style,
785        })
786        .collect();
787    // Outer spans first, so inner (more nested) spans are combined last and win.
788    //
789    // Upstream is `sorted(spans[::-1], key=attrgetter("start"))`, and both halves
790    // matter. Spans are pushed in *closing* order, innermost first, so the
791    // reverse puts the outermost first; the sort then keys on `start` **only**,
792    // and being stable it preserves that reversal for ties. Sorting by
793    // `(start, end desc)` instead looks equivalent but is not: two tags covering
794    // the exact same range compare Equal, the innermost stays first, and the
795    // outer tag ends up winning. `[red][blue]x[/][/]` must render blue.
796    spans.reverse();
797    spans.sort_by_key(|span| span.start);
798
799    let mut text = Text::styled(plain, base_style.clone());
800    for span in spans {
801        text.push_span(span);
802    }
803    Ok(text)
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    /// `tag_matches` finds what `RE_TAGS.captures_iter` finds, group for
811    /// group, on every string of up to six characters from the ones that
812    /// matter to it, and on longer random ones.
813    #[test]
814    fn tag_matches_are_re_tags() {
815        const ALPHABET: [char; 9] = ['\\', '[', ']', 'a', 'Z', '/', '@', '\n', 'é'];
816        let reference = |text: &str| -> Vec<TagMatch> {
817            RE_TAGS
818                .captures_iter(text)
819                .map(|captures| {
820                    let captures = captures.unwrap();
821                    let whole = captures.get(1).unwrap();
822                    let escapes = captures.get(2).unwrap();
823                    let tag = captures.get(3).unwrap();
824                    assert_eq!(tag.start(), escapes.end() + 1);
825                    assert_eq!(tag.end() + 1, whole.end());
826                    TagMatch {
827                        start: whole.start(),
828                        bracket: escapes.end(),
829                        end: whole.end(),
830                    }
831                })
832                .collect()
833        };
834        let check = |text: &str| assert_eq!(tag_matches(text), reference(text), "{text:?}");
835        let mut texts = vec![String::new()];
836        for _ in 0..6 {
837            texts = texts
838                .iter()
839                .flat_map(|text| ALPHABET.iter().map(move |c| format!("{text}{c}")))
840                .collect();
841            texts.iter().for_each(|text| check(text));
842        }
843        let mut seed = 0x9e37_79b9u32;
844        for _ in 0..20_000 {
845            seed ^= seed << 13;
846            seed ^= seed >> 17;
847            seed ^= seed << 5;
848            let length = seed as usize % 40;
849            let text: String = (0..length)
850                .map(|_| {
851                    seed ^= seed << 13;
852                    seed ^= seed >> 17;
853                    seed ^= seed << 5;
854                    ALPHABET[seed as usize % ALPHABET.len()]
855                })
856                .collect();
857            check(&text);
858        }
859        check("[bold red]x[/] \\[b]y\\\\[i]z[link=https://a.b/c?d=[e]]");
860    }
861
862    /// Error positions are character offsets into the original markup, and
863    /// emoji codes are replaced only between tags.
864    #[test]
865    fn error_positions_count_characters_of_the_original() {
866        let message = |source: &str| match render_emoji(source) {
867            Err(RichError::Markup(message)) => message,
868            other => panic!("{other:?}"),
869        };
870        assert!(message("é中[/i]").contains("position 2 "));
871        assert!(message(":smile: [/i]").contains("position 8 "));
872        assert_eq!(render_emoji("[b]:smile:[/b]").unwrap().plain(), "😄");
873        assert_eq!(render("[b]:smile:[/b]").unwrap().plain(), ":smile:");
874    }
875    use crate::color::ColorSystem;
876    use crate::theme::Theme;
877
878    fn render_to_ansi(markup: &str) -> String {
879        let text = render(markup).unwrap();
880        let segments = text.render(&Theme::default_theme(), &Style::new());
881        segments
882            .iter()
883            .map(|s| {
884                s.style
885                    .clone()
886                    .unwrap_or_default()
887                    .render(&s.text, Some(ColorSystem::Truecolor))
888            })
889            .collect()
890    }
891
892    #[test]
893    fn simple_tags() {
894        assert_eq!(
895            render_to_ansi("[bold]Hello[/] [red]World[/]"),
896            "\x1b[1mHello\x1b[0m \x1b[31mWorld\x1b[0m"
897        );
898    }
899
900    /// A control code must never reach `plain`, because span offsets are
901    /// computed against it as it is built. Stripping later shifts the text out
902    /// from under the offsets, and a boundary landing inside a multi-byte
903    /// character panics on slicing.
904    ///
905    /// Regression: `rich --print` on any input containing a CR — i.e. any file
906    /// with CRLF line endings — panicked.
907    #[test]
908    fn control_codes_never_desynchronise_span_offsets() {
909        let text = render("a\r[red]\u{2593}x[/]").expect("valid markup");
910        assert_eq!(text.plain(), "a\u{2593}x");
911        for span in text.spans() {
912            assert!(
913                text.plain().is_char_boundary(span.start)
914                    && text.plain().is_char_boundary(span.end),
915                "span {span:?} does not land on char boundaries of {:?}",
916                text.plain()
917            );
918        }
919        // Rendering is what actually panicked, so exercise it.
920        let rendered = render_to_ansi("a\r[red]\u{2593}x[/]");
921        assert!(rendered.contains('\u{2593}'), "got {rendered:?}");
922    }
923
924    #[test]
925    fn nested_inner_wins() {
926        // outer red, inner blue -> the inner color should apply to 'x'
927        let out = render_to_ansi("[red]a[blue]x[/]b[/]");
928        assert_eq!(out, "\x1b[31ma\x1b[0m\x1b[34mx\x1b[0m\x1b[31mb\x1b[0m");
929    }
930
931    #[test]
932    fn escaped_bracket_is_literal() {
933        let text = render("\\[not a tag]").unwrap();
934        assert_eq!(text.plain(), "[not a tag]");
935    }
936
937    #[test]
938    fn theme_name_resolves() {
939        // An upstream style name resolves via the theme rather than being
940        // parsed as an inline definition: "repr.number" -> bold not-italic cyan.
941        // Captured from real rich 15.0.0: `[repr.number]7[/]`.
942        assert_eq!(render_to_ansi("[repr.number]7[/]"), "\x1b[1;36m7\x1b[0m");
943    }
944
945    #[test]
946    fn none_is_the_null_style() {
947        // Upstream's `Style.parse` special-cases a bare "none" (many
948        // DEFAULT_STYLES entries are exactly that); as a *word* inside a longer
949        // definition it is still an error.
950        assert!(Style::parse("none").unwrap().is_null());
951        assert!(Style::parse("").unwrap().is_null());
952        assert!(Style::parse("bold none").is_err());
953    }
954
955    #[test]
956    fn bracket_is_literal_unless_tag_start() {
957        // Upstream only treats `[a-z#/@]`-led brackets as tags.
958        assert_eq!(render("[Hello] world").unwrap().plain(), "[Hello] world");
959        assert_eq!(render("[42] x").unwrap().plain(), "[42] x");
960    }
961
962    #[test]
963    fn hex_tag_and_meta_tag() {
964        // `#` starts a tag; `@` tags carry no visible style.
965        assert_eq!(
966            render_to_ansi("[#ff0000]x[/]"),
967            "\x1b[38;2;255;0;0mx\x1b[0m"
968        );
969        assert_eq!(render_to_ansi("[@foo]y[/]"), "y");
970    }
971
972    #[test]
973    fn unmatched_closing_tags_error() {
974        assert!(render("a[/]b").is_err());
975        assert!(render("[bold]a[/red]").is_err());
976        assert!(render("x[/red]y").is_err());
977        // Unclosed *opening* tags are auto-closed, not an error.
978        assert!(render("[bold]hi").is_ok());
979    }
980
981    #[test]
982    fn escape_matches_upstream() {
983        assert_eq!(escape("[bold]"), "\\[bold]");
984        assert_eq!(escape("a[b]c"), "a\\[b]c");
985        assert_eq!(escape("back\\slash"), "back\\slash");
986        assert_eq!(escape("trailing\\"), "trailing\\\\");
987        assert_eq!(escape("[Hello]"), "[Hello]"); // not a tag → unchanged
988                                                  // Escaped markup round-trips to the literal text.
989        assert_eq!(render(&escape("[bold]")).unwrap().plain(), "[bold]");
990    }
991}