Skip to main content

rusty_rich/
markup.rs

1//! Console markup parser — equivalent to Rich's `markup.py`.
2//!
3//! Supports Rich's BBCode-like markup syntax:
4//!
5//! - `[bold]text[/bold]` — apply bold
6//! - `[red]text[/red]` — set color
7//! - `[on blue]text[/on blue]` — set background
8//! - `[bold red on blue]text[/]` — combined styling
9//! - `[/]` — close all open tags
10//! - `[[` — literal `[`
11
12use crate::style::{Style, StyleStack};
13use crate::text::Text;
14
15// ---------------------------------------------------------------------------
16// Tag
17// ---------------------------------------------------------------------------
18
19/// A parsed markup tag.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Tag {
22    pub name: String,
23    pub parameters: Option<String>,
24}
25
26impl Tag {
27    pub fn new(name: impl Into<String>) -> Self {
28        Self {
29            name: name.into(),
30            parameters: None,
31        }
32    }
33
34    pub fn with_params(name: impl Into<String>, params: impl Into<String>) -> Self {
35        Self {
36            name: name.into(),
37            parameters: Some(params.into()),
38        }
39    }
40
41    /// Check if this is a closing tag (`/name` or `/`).
42    pub fn is_closing(&self) -> bool {
43        self.name == "/" || self.name.starts_with('/')
44    }
45
46    /// Get the name without the leading `/` for closing tags.
47    pub fn closing_name(&self) -> &str {
48        if self.name == "/" {
49            ""
50        } else {
51            &self.name[1..]
52        }
53    }
54
55    /// Get the markup string for this tag.
56    pub fn markup(&self) -> String {
57        if let Some(ref params) = self.parameters {
58            format!("[{}={}]", self.name, params)
59        } else {
60            format!("[{}]", self.name)
61        }
62    }
63}
64
65// ---------------------------------------------------------------------------
66// Parser
67// ---------------------------------------------------------------------------
68
69/// Maximum nesting depth for markup tags (prevents stack overflow from
70/// deeply nested input like `[bold][bold]...`).
71const MAX_MARKUP_DEPTH: usize = 100;
72
73/// Parse markup and return a `Text` with applied styles.
74///
75/// Uses byte-based scanning (since `[` and `]` are ASCII single-byte) to
76/// avoid allocating a `Vec<char>`.  Literal text is sanitized to prevent
77/// raw ANSI escape injection.
78pub fn render(markup: &str) -> Text {
79    let mut text = Text::new("");
80    let mut style_stack = StyleStack::new(Style::new());
81
82    let bytes = markup.as_bytes();
83    let len = bytes.len();
84    let mut pos = 0usize;
85
86    while pos < len {
87        if bytes[pos] == b'[' {
88            // Check for escaped `[[`
89            if pos + 1 < len && bytes[pos + 1] == b'[' {
90                text.append_styled("[", style_stack.current());
91                pos += 2;
92                continue;
93            }
94
95            // Find the closing `]`
96            let end = match bytes[pos..].iter().position(|&c| c == b']') {
97                Some(e) => pos + e,
98                None => {
99                    // No closing bracket — treat as literal
100                    text.append_styled("[", style_stack.current());
101                    pos += 1;
102                    continue;
103                }
104            };
105
106            // Extract tag string (bytes between [ and ] are ASCII-safe)
107            let tag_str = std::str::from_utf8(&bytes[pos + 1..end]).unwrap_or("");
108            pos = end + 1;
109
110            if tag_str.is_empty() {
111                continue;
112            }
113
114            // Parse the tag
115            let tag = parse_tag(tag_str);
116
117            if tag.is_closing() {
118                let closing = tag.closing_name();
119                if closing.is_empty() {
120                    // [/] — close all
121                    while !style_stack.is_empty() {
122                        style_stack.pop();
123                    }
124                } else {
125                    // [/name] — pop to matching opening tag
126                    style_stack.pop_to(closing);
127                }
128            } else {
129                // Opening tag — push style with tag name for matching
130                // Guard against unlimited nesting depth (BUG-007)
131                if style_stack.len() < MAX_MARKUP_DEPTH {
132                    let style = tag_to_style(&tag);
133                    style_stack.push_named(tag.name.clone(), style);
134                }
135            }
136        } else {
137            // Regular text — accumulate until next `[` or end
138            let start = pos;
139            while pos < len && bytes[pos] != b'[' {
140                pos += 1;
141            }
142            // start..pos is at valid UTF-8 boundaries because we never split
143            // inside a multi-byte character (we stop at ASCII '[', and the
144            // range starts after a ']' or at the beginning of the string).
145            let chunk = &markup[start..pos];
146            // Sanitize to prevent raw ANSI escape injection in literal text
147            let sanitized = crate::export::strip_ansi_escapes(chunk);
148            text.append_styled(sanitized, style_stack.current());
149        }
150    }
151
152    text
153}
154
155/// Parse a tag string into a Tag.
156fn parse_tag(s: &str) -> Tag {
157    // Handle "/" or "/name"
158    if s.starts_with('/') {
159        return Tag::new(s.to_string());
160    }
161
162    // Check for `name=value`
163    if let Some(eq) = s.find('=') {
164        let name = s[..eq].to_string();
165        let value = s[eq + 1..].to_string();
166        // Strip quotes if present
167        let value = value.trim_matches('"').trim_matches('\'').to_string();
168        return Tag::with_params(name, value);
169    }
170
171    // Check for `name(params)`
172    if let Some(lparen) = s.find('(') {
173        if s.ends_with(')') {
174            let name = s[..lparen].to_string();
175            let params = s[lparen + 1..s.len() - 1].to_string();
176            return Tag::with_params(name, params);
177        }
178    }
179
180    Tag::new(s.to_string())
181}
182
183/// Convert a tag to a Style.
184fn tag_to_style(tag: &Tag) -> Style {
185    let name = &tag.name;
186
187    match name.as_str() {
188        "bold" | "b" => Style::new().bold(true),
189        "dim" | "d" => Style::new().dim(true),
190        "italic" | "i" => Style::new().italic(true),
191        "underline" | "u" => Style::new().underline(true),
192        "blink" => Style::new().blink(true),
193        "reverse" | "r" => Style::new().reverse(true),
194        "strike" | "s" => Style::new().strike(true),
195
196        "/bold" | "/b" | "/dim" | "/d" | "/italic" | "/i" | "/underline" | "/u" | "/blink"
197        | "/reverse" | "/r" | "/strike" | "/s" => Style::null(),
198
199        _ => {
200            // Try as color name, including "on <color>"
201            if let Some(color_name) = name.strip_prefix("on ") {
202                if let Ok(c) = crate::color::Color::parse(color_name) {
203                    return Style::new().bgcolor(c);
204                }
205            }
206
207            // Try "color on bgcolor"
208            if let Some(on_pos) = name.find(" on ") {
209                let fg_name = &name[..on_pos];
210                let bg_name = &name[on_pos + 4..];
211                if let Ok(fg) = crate::color::Color::parse(fg_name) {
212                    let mut style = Style::new().color(fg);
213                    if let Ok(bg) = crate::color::Color::parse(bg_name) {
214                        style = style.bgcolor(bg);
215                    }
216                    return style;
217                }
218            }
219
220            // Try as a plain color name
221            if let Ok(c) = crate::color::Color::parse(name) {
222                return Style::new().color(c);
223            }
224
225            // Try from parameters (e.g. [color(1)] or [color=red])
226            if let Some(ref params) = tag.parameters {
227                if let Ok(c) = crate::color::Color::parse(params) {
228                    return Style::new().color(c);
229                }
230            }
231
232            // Unknown tag — return empty style
233            Style::new()
234        }
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Escape markup
240// ---------------------------------------------------------------------------
241
242/// Escape text so it won't be interpreted as markup.
243pub fn escape(markup: &str) -> String {
244    markup.replace('[', "[[")
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_escape() {
253        assert_eq!(escape("[bold]"), "[[bold]");
254    }
255
256    #[test]
257    fn test_render_bold() {
258        let t = render("[bold]Hello[/bold]");
259        assert_eq!(t.plain, "Hello");
260        assert!(!t.spans.is_empty()); // has style spans
261    }
262
263    #[test]
264    fn test_render_literal_bracket() {
265        let t = render("[[hello]]");
266        // Escaped brackets produce the bracket followed by literal text then closing bracket
267        assert!(t.plain.contains("hello"));
268    }
269
270    #[test]
271    fn test_render_color() {
272        let t = render("[red]red text[/red]");
273        assert_eq!(t.plain, "red text");
274        assert!(!t.spans.is_empty());
275    }
276
277    #[test]
278    fn test_parse_tag() {
279        let tag = parse_tag("bold");
280        assert_eq!(tag.name, "bold");
281
282        let tag = parse_tag("color=red");
283        assert_eq!(tag.name, "color");
284        assert_eq!(tag.parameters, Some("red".into()));
285
286        let tag = parse_tag("/bold");
287        assert!(tag.is_closing());
288    }
289}