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 tokens.push(MarkupToken::OpenTag(style, link));
102 }
103 }
104 ':' => {
105 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 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 current_text.push(':');
130 current_text.push_str(&emoji_name);
131 if found_close {
132 current_text.push(':');
133 }
134 }
135 }
136 ']' => {
137 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 if !current_text.is_empty() {
153 tokens.push(MarkupToken::Text(current_text));
154 }
155
156 tokens
157}
158
159pub 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
201pub 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}