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/// Parse markup and return a `Text` with applied styles.
70pub fn render(markup: &str) -> Text {
71    let mut text = Text::new("");
72    let mut style_stack = StyleStack::new(Style::new());
73    let mut pos = 0usize;
74
75    let chars: Vec<char> = markup.chars().collect();
76    let len = chars.len();
77
78    while pos < len {
79        if chars[pos] == '[' {
80            // Check for escaped `[[`
81            if pos + 1 < len && chars[pos + 1] == '[' {
82                text.append_styled("[", style_stack.current());
83                pos += 2;
84                continue;
85            }
86
87            // Find the closing `]`
88            let end = match chars[pos..].iter().position(|&c| c == ']') {
89                Some(e) => pos + e,
90                None => {
91                    // No closing bracket — treat as literal
92                    text.append_styled("[", style_stack.current());
93                    pos += 1;
94                    continue;
95                }
96            };
97
98            let tag_str: String = chars[pos + 1..end].iter().collect();
99            pos = end + 1;
100
101            if tag_str.is_empty() {
102                continue;
103            }
104
105            // Parse the tag
106            let tag = parse_tag(&tag_str);
107
108            if tag.is_closing() {
109                let closing = tag.closing_name();
110                if closing.is_empty() {
111                    // [/] — close all
112                    while style_stack.len() > 0 {
113                        style_stack.pop();
114                    }
115                } else {
116                    // [/name] — pop until we find matching
117                    // Simplified: just pop one
118                    style_stack.pop();
119                }
120            } else {
121                // Opening tag — push style
122                let style = tag_to_style(&tag);
123                style_stack.push(style);
124            }
125        } else {
126            // Regular text — accumulate until next `[` or end
127            let start = pos;
128            while pos < len && chars[pos] != '[' {
129                pos += 1;
130            }
131            let chunk: String = chars[start..pos].iter().collect();
132            text.append_styled(chunk, style_stack.current());
133        }
134    }
135
136    text
137}
138
139/// Parse a tag string into a Tag.
140fn parse_tag(s: &str) -> Tag {
141    // Handle "/" or "/name"
142    if s.starts_with('/') {
143        return Tag::new(s.to_string());
144    }
145
146    // Check for `name=value`
147    if let Some(eq) = s.find('=') {
148        let name = s[..eq].to_string();
149        let value = s[eq + 1..].to_string();
150        // Strip quotes if present
151        let value = value.trim_matches('"').trim_matches('\'').to_string();
152        return Tag::with_params(name, value);
153    }
154
155    // Check for `name(params)`
156    if let Some(lparen) = s.find('(') {
157        if s.ends_with(')') {
158            let name = s[..lparen].to_string();
159            let params = s[lparen + 1..s.len() - 1].to_string();
160            return Tag::with_params(name, params);
161        }
162    }
163
164    Tag::new(s.to_string())
165}
166
167/// Convert a tag to a Style.
168fn tag_to_style(tag: &Tag) -> Style {
169    let name = &tag.name;
170
171    match name.as_str() {
172        "bold" | "b" => Style::new().bold(true),
173        "dim" | "d" => Style::new().dim(true),
174        "italic" | "i" => Style::new().italic(true),
175        "underline" | "u" => Style::new().underline(true),
176        "blink" => Style::new().blink(true),
177        "reverse" | "r" => Style::new().reverse(true),
178        "strike" | "s" => Style::new().strike(true),
179
180        "/bold" | "/b" | "/dim" | "/d" | "/italic" | "/i"
181        | "/underline" | "/u" | "/blink" | "/reverse" | "/r"
182        | "/strike" | "/s" => Style::null(),
183
184        _ => {
185            // Try as color name, including "on <color>"
186            if name.starts_with("on ") {
187                if let Ok(c) = crate::color::Color::parse(&name[3..]) {
188                    return Style::new().bgcolor(c);
189                }
190            }
191
192            // Try "color on bgcolor"
193            if let Some(on_pos) = name.find(" on ") {
194                let fg_name = &name[..on_pos];
195                let bg_name = &name[on_pos + 4..];
196                if let Ok(fg) = crate::color::Color::parse(fg_name) {
197                    let mut style = Style::new().color(fg);
198                    if let Ok(bg) = crate::color::Color::parse(bg_name) {
199                        style = style.bgcolor(bg);
200                    }
201                    return style;
202                }
203            }
204
205            // Try as a plain color name
206            if let Ok(c) = crate::color::Color::parse(name) {
207                return Style::new().color(c);
208            }
209
210            // Try from parameters (e.g. [color(1)] or [color=red])
211            if let Some(ref params) = tag.parameters {
212                if let Ok(c) = crate::color::Color::parse(params) {
213                    return Style::new().color(c);
214                }
215            }
216
217            // Unknown tag — return empty style
218            Style::new()
219        }
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Escape markup
225// ---------------------------------------------------------------------------
226
227/// Escape text so it won't be interpreted as markup.
228pub fn escape(markup: &str) -> String {
229    markup.replace('[', "[[")
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_escape() {
238        assert_eq!(escape("[bold]"), "[[bold]");
239    }
240
241    #[test]
242    fn test_render_bold() {
243        let t = render("[bold]Hello[/bold]");
244        assert_eq!(t.plain, "Hello");
245        assert!(!t.spans.is_empty()); // has style spans
246    }
247
248    #[test]
249    fn test_render_literal_bracket() {
250        let t = render("[[hello]]");
251        // Escaped brackets produce the bracket followed by literal text then closing bracket
252        assert!(t.plain.contains("hello"));
253    }
254
255    #[test]
256    fn test_render_color() {
257        let t = render("[red]red text[/red]");
258        assert_eq!(t.plain, "red text");
259        assert!(!t.spans.is_empty());
260    }
261
262    #[test]
263    fn test_parse_tag() {
264        let tag = parse_tag("bold");
265        assert_eq!(tag.name, "bold");
266
267        let tag = parse_tag("color=red");
268        assert_eq!(tag.name, "color");
269        assert_eq!(tag.parameters, Some("red".into()));
270
271        let tag = parse_tag("/bold");
272        assert!(tag.is_closing());
273    }
274}