fast_rich/
markup.rs

1//! Markup parser for Rich-style markup syntax.
2//!
3//! Parses text like `[bold red]Hello[/]` into styled spans.
4//!
5//! # Syntax
6//!
7//! - `[style]text[/]` - Apply style to text
8//! - `[style]text[/style]` - Explicit close tag
9//! - `[bold red on blue]` - Multiple styles
10//! - `[@emoji_name]` - Emoji shortcode (handled separately)
11//! - `[[` - Escaped `[`
12//! - `]]` - Escaped `]`
13
14use crate::style::Style;
15use crate::text::{Span, Text};
16
17/// A parsed token from markup text.
18#[derive(Debug, Clone, PartialEq)]
19pub enum MarkupToken {
20    /// Plain text
21    Text(String),
22    /// Opening style tag with optional hyperlink
23    OpenTag(Style, Option<String>),
24    /// Closing tag
25    CloseTag,
26    /// Emoji shortcode like :smile:
27    Emoji(String),
28}
29
30/// Parse markup text into tokens.
31pub fn tokenize(input: &str) -> Vec<MarkupToken> {
32    let mut tokens = Vec::new();
33    let mut chars = input.chars().peekable();
34    let mut current_text = String::new();
35
36    while let Some(c) = chars.next() {
37        match c {
38            '[' => {
39                // Check for escape sequence [[
40                if chars.peek() == Some(&'[') {
41                    chars.next();
42                    current_text.push('[');
43                    continue;
44                }
45
46                // Flush current text
47                if !current_text.is_empty() {
48                    tokens.push(MarkupToken::Text(std::mem::take(&mut current_text)));
49                }
50
51                // Parse tag content
52                let mut tag_content = String::new();
53                let mut found_close = false;
54
55                while let Some(&c) = chars.peek() {
56                    if c == ']' {
57                        // Check for escape sequence ]]
58                        chars.next();
59                        if chars.peek() == Some(&']') {
60                            chars.next();
61                            tag_content.push(']');
62                        } else {
63                            found_close = true;
64                            break;
65                        }
66                    } else {
67                        tag_content.push(chars.next().unwrap());
68                    }
69                }
70
71                if !found_close {
72                    // Unterminated tag, treat as text
73                    current_text.push('[');
74                    current_text.push_str(&tag_content);
75                    continue;
76                }
77
78                // Parse the tag content
79                let tag_content = tag_content.trim();
80
81                if tag_content.is_empty() || tag_content == "/" {
82                    // Close tag
83                    tokens.push(MarkupToken::CloseTag);
84                } else if tag_content.starts_with('/') {
85                    // Explicit close tag like [/bold]
86                    tokens.push(MarkupToken::CloseTag);
87                } else {
88                    // Check for link=URL attribute
89                    let mut link: Option<String> = None;
90                    let mut style_parts = Vec::new();
91
92                    for part in tag_content.split_whitespace() {
93                        if let Some(url) = part.strip_prefix("link=") {
94                            link = Some(url.to_string());
95                        } else {
96                            style_parts.push(part);
97                        }
98                    }
99
100                    let style = Style::parse(&style_parts.join(" "));
101
102                    // Heuristic: If we parsed no style and no link, it's likely not a tag
103                    // (e.g. "[1, 2, 3]" from debug output). Treat it as literal text.
104                    if style.is_empty() && link.is_none() {
105                        // Reconstruct the original text
106                        let original = if tag_content.contains(']') {
107                            // If we had unescaped brackets inside, we might lose fidelity here
108                            // but for standard debug output it's usually fine.
109                            format!("[{}]", tag_content)
110                        } else {
111                            format!("[{}]", tag_content)
112                        };
113                        tokens.push(MarkupToken::Text(original));
114                    } else {
115                        tokens.push(MarkupToken::OpenTag(style, link));
116                    }
117                }
118            }
119            ':' => {
120                // Check for emoji
121                let mut emoji_name = String::new();
122                let mut found_close = false;
123
124                while let Some(&c) = chars.peek() {
125                    if c == ':' {
126                        chars.next();
127                        found_close = true;
128                        break;
129                    } else if c.is_alphanumeric() || c == '_' || c == '-' {
130                        emoji_name.push(chars.next().unwrap());
131                    } else {
132                        break;
133                    }
134                }
135
136                if found_close && !emoji_name.is_empty() {
137                    // Flush current text
138                    if !current_text.is_empty() {
139                        tokens.push(MarkupToken::Text(std::mem::take(&mut current_text)));
140                    }
141                    tokens.push(MarkupToken::Emoji(emoji_name));
142                } else {
143                    // Not an emoji, treat as regular text
144                    current_text.push(':');
145                    current_text.push_str(&emoji_name);
146                    if found_close {
147                        current_text.push(':');
148                    }
149                }
150            }
151            ']' => {
152                // Check for escape sequence ]]
153                if chars.peek() == Some(&']') {
154                    chars.next();
155                    current_text.push(']');
156                } else {
157                    current_text.push(']');
158                }
159            }
160            _ => {
161                current_text.push(c);
162            }
163        }
164    }
165
166    // Flush remaining text
167    if !current_text.is_empty() {
168        tokens.push(MarkupToken::Text(current_text));
169    }
170
171    tokens
172}
173
174/// Parse markup text into styled Text.
175pub fn parse(input: &str) -> Text {
176    let tokens = tokenize(input);
177    let mut spans = Vec::new();
178    let mut style_stack: Vec<Style> = Vec::new();
179    let mut link_stack: Vec<Option<String>> = Vec::new();
180
181    for token in tokens {
182        match token {
183            MarkupToken::Text(text) => {
184                let style = style_stack.last().cloned().unwrap_or_default();
185                let link = link_stack.iter().rev().find_map(|l| l.clone());
186                if let Some(url) = link {
187                    spans.push(Span::linked(text, style, url));
188                } else {
189                    spans.push(Span::styled(text, style));
190                }
191            }
192            MarkupToken::OpenTag(style, link) => {
193                let combined = if let Some(current) = style_stack.last() {
194                    current.combine(&style)
195                } else {
196                    style
197                };
198                style_stack.push(combined);
199                link_stack.push(link);
200            }
201            MarkupToken::CloseTag => {
202                style_stack.pop();
203                link_stack.pop();
204            }
205            MarkupToken::Emoji(name) => {
206                let emoji = crate::emoji::get_emoji(&name).unwrap_or(&name);
207                let style = style_stack.last().cloned().unwrap_or_default();
208                spans.push(Span::styled(emoji.to_string(), style));
209            }
210        }
211    }
212
213    Text::from_spans(spans)
214}
215
216/// Render markup to a plain string (for testing/debugging).
217pub fn render_plain(input: &str) -> String {
218    parse(input).plain_text()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::style::Color;
225
226    #[test]
227    fn test_tokenize_plain() {
228        let tokens = tokenize("Hello, World!");
229        assert_eq!(tokens, vec![MarkupToken::Text("Hello, World!".to_string())]);
230    }
231
232    #[test]
233    fn test_tokenize_styled() {
234        let tokens = tokenize("[bold]Hello[/]");
235        assert_eq!(tokens.len(), 3);
236        assert!(matches!(tokens[0], MarkupToken::OpenTag(_, _)));
237        assert_eq!(tokens[1], MarkupToken::Text("Hello".to_string()));
238        assert_eq!(tokens[2], MarkupToken::CloseTag);
239    }
240
241    #[test]
242    fn test_tokenize_nested() {
243        let tokens = tokenize("[bold][red]Hi[/][/]");
244        assert_eq!(tokens.len(), 5);
245    }
246
247    #[test]
248    fn test_tokenize_escape_brackets() {
249        let tokens = tokenize("[[escaped]]");
250        assert_eq!(tokens, vec![MarkupToken::Text("[escaped]".to_string())]);
251    }
252
253    #[test]
254    fn test_tokenize_emoji() {
255        let tokens = tokenize(":smile:");
256        assert_eq!(tokens, vec![MarkupToken::Emoji("smile".to_string())]);
257    }
258
259    #[test]
260    fn test_parse_plain() {
261        let text = parse("Hello, World!");
262        assert_eq!(text.plain_text(), "Hello, World!");
263    }
264
265    #[test]
266    fn test_parse_styled() {
267        let text = parse("[bold]Hello[/]");
268        assert_eq!(text.plain_text(), "Hello");
269        assert_eq!(text.spans.len(), 1);
270        assert!(text.spans[0].style.bold);
271    }
272
273    #[test]
274    fn test_parse_multiple_styles() {
275        let text = parse("[bold red]Hello[/]");
276        assert!(text.spans[0].style.bold);
277        assert_eq!(text.spans[0].style.foreground, Some(Color::Red));
278    }
279
280    #[test]
281    fn test_parse_nested() {
282        let text = parse("[bold]Hello [italic]World[/][/]");
283        assert_eq!(text.plain_text(), "Hello World");
284        assert!(text.spans[0].style.bold);
285        assert!(text.spans[1].style.bold);
286        assert!(text.spans[1].style.italic);
287    }
288
289    #[test]
290    fn test_parse_background() {
291        let text = parse("[white on red]Alert[/]");
292        assert_eq!(text.spans[0].style.foreground, Some(Color::White));
293        assert_eq!(text.spans[0].style.background, Some(Color::Red));
294    }
295
296    #[test]
297    fn test_parse_hyperlink() {
298        let text = parse("[link=https://example.com]Click here[/]");
299        assert_eq!(text.plain_text(), "Click here");
300        assert_eq!(text.spans[0].link, Some("https://example.com".to_string()));
301    }
302
303    #[test]
304    fn test_parse_hyperlink_with_style() {
305        let text = parse("[bold blue link=https://google.com]Google[/]");
306        assert_eq!(text.plain_text(), "Google");
307        assert!(text.spans[0].style.bold);
308        assert_eq!(text.spans[0].style.foreground, Some(Color::Blue));
309        assert_eq!(text.spans[0].link, Some("https://google.com".to_string()));
310    }
311}