1use crate::style::Style;
15use crate::text::{Span, Text};
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum MarkupToken {
20 Text(String),
22 OpenTag(Style, Option<String>),
24 CloseTag,
26 Emoji(String),
28}
29
30pub 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 if chars.peek() == Some(&'[') {
41 chars.next();
42 current_text.push('[');
43 continue;
44 }
45
46 if !current_text.is_empty() {
48 tokens.push(MarkupToken::Text(std::mem::take(&mut current_text)));
49 }
50
51 let mut tag_content = String::new();
53 let mut found_close = false;
54
55 while let Some(&c) = chars.peek() {
56 if c == ']' {
57 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 current_text.push('[');
74 current_text.push_str(&tag_content);
75 continue;
76 }
77
78 let tag_content = tag_content.trim();
80
81 if tag_content.is_empty() || tag_content == "/" {
82 tokens.push(MarkupToken::CloseTag);
84 } else if tag_content.starts_with('/') {
85 tokens.push(MarkupToken::CloseTag);
87 } else {
88 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 if style.is_empty() && link.is_none() {
105 let original = if tag_content.contains(']') {
107 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 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 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 current_text.push(':');
145 current_text.push_str(&emoji_name);
146 if found_close {
147 current_text.push(':');
148 }
149 }
150 }
151 ']' => {
152 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 if !current_text.is_empty() {
168 tokens.push(MarkupToken::Text(current_text));
169 }
170
171 tokens
172}
173
174pub 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
216pub 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}