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                    tokens.push(MarkupToken::OpenTag(style, link));
102                }
103            }
104            ':' => {
105                // Check for emoji
106                let mut emoji_name = String::new();
107                let mut found_close = false;
108
109                while let Some(&c) = chars.peek() {
110                    if c == ':' {
111                        chars.next();
112                        found_close = true;
113                        break;
114                    } else if c.is_alphanumeric() || c == '_' || c == '-' {
115                        emoji_name.push(chars.next().unwrap());
116                    } else {
117                        break;
118                    }
119                }
120
121                if found_close && !emoji_name.is_empty() {
122                    // Flush current text
123                    if !current_text.is_empty() {
124                        tokens.push(MarkupToken::Text(std::mem::take(&mut current_text)));
125                    }
126                    tokens.push(MarkupToken::Emoji(emoji_name));
127                } else {
128                    // Not an emoji, treat as regular text
129                    current_text.push(':');
130                    current_text.push_str(&emoji_name);
131                    if found_close {
132                        current_text.push(':');
133                    }
134                }
135            }
136            ']' => {
137                // Check for escape sequence ]]
138                if chars.peek() == Some(&']') {
139                    chars.next();
140                    current_text.push(']');
141                } else {
142                    current_text.push(']');
143                }
144            }
145            _ => {
146                current_text.push(c);
147            }
148        }
149    }
150
151    // Flush remaining text
152    if !current_text.is_empty() {
153        tokens.push(MarkupToken::Text(current_text));
154    }
155
156    tokens
157}
158
159/// Parse markup text into styled Text.
160pub fn parse(input: &str) -> Text {
161    let tokens = tokenize(input);
162    let mut spans = Vec::new();
163    let mut style_stack: Vec<Style> = Vec::new();
164    let mut link_stack: Vec<Option<String>> = Vec::new();
165
166    for token in tokens {
167        match token {
168            MarkupToken::Text(text) => {
169                let style = style_stack.last().cloned().unwrap_or_default();
170                let link = link_stack.iter().rev().find_map(|l| l.clone());
171                if let Some(url) = link {
172                    spans.push(Span::linked(text, style, url));
173                } else {
174                    spans.push(Span::styled(text, style));
175                }
176            }
177            MarkupToken::OpenTag(style, link) => {
178                let combined = if let Some(current) = style_stack.last() {
179                    current.combine(&style)
180                } else {
181                    style
182                };
183                style_stack.push(combined);
184                link_stack.push(link);
185            }
186            MarkupToken::CloseTag => {
187                style_stack.pop();
188                link_stack.pop();
189            }
190            MarkupToken::Emoji(name) => {
191                let emoji = crate::emoji::get_emoji(&name).unwrap_or(&name);
192                let style = style_stack.last().cloned().unwrap_or_default();
193                spans.push(Span::styled(emoji.to_string(), style));
194            }
195        }
196    }
197
198    Text::from_spans(spans)
199}
200
201/// Render markup to a plain string (for testing/debugging).
202pub fn render_plain(input: &str) -> String {
203    parse(input).plain_text()
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::style::Color;
210
211    #[test]
212    fn test_tokenize_plain() {
213        let tokens = tokenize("Hello, World!");
214        assert_eq!(tokens, vec![MarkupToken::Text("Hello, World!".to_string())]);
215    }
216
217    #[test]
218    fn test_tokenize_styled() {
219        let tokens = tokenize("[bold]Hello[/]");
220        assert_eq!(tokens.len(), 3);
221        assert!(matches!(tokens[0], MarkupToken::OpenTag(_, _)));
222        assert_eq!(tokens[1], MarkupToken::Text("Hello".to_string()));
223        assert_eq!(tokens[2], MarkupToken::CloseTag);
224    }
225
226    #[test]
227    fn test_tokenize_nested() {
228        let tokens = tokenize("[bold][red]Hi[/][/]");
229        assert_eq!(tokens.len(), 5);
230    }
231
232    #[test]
233    fn test_tokenize_escape_brackets() {
234        let tokens = tokenize("[[escaped]]");
235        assert_eq!(tokens, vec![MarkupToken::Text("[escaped]".to_string())]);
236    }
237
238    #[test]
239    fn test_tokenize_emoji() {
240        let tokens = tokenize(":smile:");
241        assert_eq!(tokens, vec![MarkupToken::Emoji("smile".to_string())]);
242    }
243
244    #[test]
245    fn test_parse_plain() {
246        let text = parse("Hello, World!");
247        assert_eq!(text.plain_text(), "Hello, World!");
248    }
249
250    #[test]
251    fn test_parse_styled() {
252        let text = parse("[bold]Hello[/]");
253        assert_eq!(text.plain_text(), "Hello");
254        assert_eq!(text.spans.len(), 1);
255        assert!(text.spans[0].style.bold);
256    }
257
258    #[test]
259    fn test_parse_multiple_styles() {
260        let text = parse("[bold red]Hello[/]");
261        assert!(text.spans[0].style.bold);
262        assert_eq!(text.spans[0].style.foreground, Some(Color::Red));
263    }
264
265    #[test]
266    fn test_parse_nested() {
267        let text = parse("[bold]Hello [italic]World[/][/]");
268        assert_eq!(text.plain_text(), "Hello World");
269        assert!(text.spans[0].style.bold);
270        assert!(text.spans[1].style.bold);
271        assert!(text.spans[1].style.italic);
272    }
273
274    #[test]
275    fn test_parse_background() {
276        let text = parse("[white on red]Alert[/]");
277        assert_eq!(text.spans[0].style.foreground, Some(Color::White));
278        assert_eq!(text.spans[0].style.background, Some(Color::Red));
279    }
280
281    #[test]
282    fn test_parse_hyperlink() {
283        let text = parse("[link=https://example.com]Click here[/]");
284        assert_eq!(text.plain_text(), "Click here");
285        assert_eq!(text.spans[0].link, Some("https://example.com".to_string()));
286    }
287
288    #[test]
289    fn test_parse_hyperlink_with_style() {
290        let text = parse("[bold blue link=https://google.com]Google[/]");
291        assert_eq!(text.plain_text(), "Google");
292        assert!(text.spans[0].style.bold);
293        assert_eq!(text.spans[0].style.foreground, Some(Color::Blue));
294        assert_eq!(text.spans[0].link, Some("https://google.com".to_string()));
295    }
296}