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}