use crate::style::Style;
use crate::text::{Span, Text};
#[derive(Debug, Clone, PartialEq)]
pub enum MarkupToken {
Text(String),
OpenTag(Style, Option<String>),
CloseTag,
Emoji(String),
}
pub fn tokenize(input: &str) -> Vec<MarkupToken> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
let mut current_text = String::new();
while let Some(c) = chars.next() {
match c {
'[' => {
if chars.peek() == Some(&'[') {
chars.next();
current_text.push('[');
continue;
}
if !current_text.is_empty() {
tokens.push(MarkupToken::Text(std::mem::take(&mut current_text)));
}
let mut tag_content = String::new();
let mut found_close = false;
while let Some(&c) = chars.peek() {
if c == ']' {
chars.next();
if chars.peek() == Some(&']') {
chars.next();
tag_content.push(']');
} else {
found_close = true;
break;
}
} else {
tag_content.push(chars.next().unwrap());
}
}
if !found_close {
current_text.push('[');
current_text.push_str(&tag_content);
continue;
}
let tag_content = tag_content.trim();
if tag_content.is_empty() || tag_content == "/" {
tokens.push(MarkupToken::CloseTag);
} else if tag_content.starts_with('/') {
tokens.push(MarkupToken::CloseTag);
} else {
let mut link: Option<String> = None;
let mut style_parts = Vec::new();
for part in tag_content.split_whitespace() {
if let Some(url) = part.strip_prefix("link=") {
link = Some(url.to_string());
} else {
style_parts.push(part);
}
}
let style = Style::parse(&style_parts.join(" "));
if style.is_empty() && link.is_none() {
let original = if tag_content.contains(']') {
format!("[{}]", tag_content)
} else {
format!("[{}]", tag_content)
};
tokens.push(MarkupToken::Text(original));
} else {
tokens.push(MarkupToken::OpenTag(style, link));
}
}
}
':' => {
let mut emoji_name = String::new();
let mut found_close = false;
while let Some(&c) = chars.peek() {
if c == ':' {
chars.next();
found_close = true;
break;
} else if c.is_alphanumeric() || c == '_' || c == '-' {
emoji_name.push(chars.next().unwrap());
} else {
break;
}
}
if found_close && !emoji_name.is_empty() {
if !current_text.is_empty() {
tokens.push(MarkupToken::Text(std::mem::take(&mut current_text)));
}
tokens.push(MarkupToken::Emoji(emoji_name));
} else {
current_text.push(':');
current_text.push_str(&emoji_name);
if found_close {
current_text.push(':');
}
}
}
']' => {
if chars.peek() == Some(&']') {
chars.next();
current_text.push(']');
} else {
current_text.push(']');
}
}
_ => {
current_text.push(c);
}
}
}
if !current_text.is_empty() {
tokens.push(MarkupToken::Text(current_text));
}
tokens
}
pub fn parse(input: &str) -> Text {
let tokens = tokenize(input);
let mut spans = Vec::new();
let mut style_stack: Vec<Style> = Vec::new();
let mut link_stack: Vec<Option<String>> = Vec::new();
for token in tokens {
match token {
MarkupToken::Text(text) => {
let style = style_stack.last().cloned().unwrap_or_default();
let link = link_stack.iter().rev().find_map(|l| l.clone());
if let Some(url) = link {
spans.push(Span::linked(text, style, url));
} else {
spans.push(Span::styled(text, style));
}
}
MarkupToken::OpenTag(style, link) => {
let combined = if let Some(current) = style_stack.last() {
current.combine(&style)
} else {
style
};
style_stack.push(combined);
link_stack.push(link);
}
MarkupToken::CloseTag => {
style_stack.pop();
link_stack.pop();
}
MarkupToken::Emoji(name) => {
let emoji = crate::emoji::get_emoji(&name).unwrap_or(&name);
let style = style_stack.last().cloned().unwrap_or_default();
spans.push(Span::styled(emoji.to_string(), style));
}
}
}
Text::from_spans(spans)
}
pub fn render_plain(input: &str) -> String {
parse(input).plain_text()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Color;
#[test]
fn test_tokenize_plain() {
let tokens = tokenize("Hello, World!");
assert_eq!(tokens, vec![MarkupToken::Text("Hello, World!".to_string())]);
}
#[test]
fn test_tokenize_styled() {
let tokens = tokenize("[bold]Hello[/]");
assert_eq!(tokens.len(), 3);
assert!(matches!(tokens[0], MarkupToken::OpenTag(_, _)));
assert_eq!(tokens[1], MarkupToken::Text("Hello".to_string()));
assert_eq!(tokens[2], MarkupToken::CloseTag);
}
#[test]
fn test_tokenize_nested() {
let tokens = tokenize("[bold][red]Hi[/][/]");
assert_eq!(tokens.len(), 5);
}
#[test]
fn test_tokenize_escape_brackets() {
let tokens = tokenize("[[escaped]]");
assert_eq!(tokens, vec![MarkupToken::Text("[escaped]".to_string())]);
}
#[test]
fn test_tokenize_emoji() {
let tokens = tokenize(":smile:");
assert_eq!(tokens, vec![MarkupToken::Emoji("smile".to_string())]);
}
#[test]
fn test_parse_plain() {
let text = parse("Hello, World!");
assert_eq!(text.plain_text(), "Hello, World!");
}
#[test]
fn test_parse_styled() {
let text = parse("[bold]Hello[/]");
assert_eq!(text.plain_text(), "Hello");
assert_eq!(text.spans.len(), 1);
assert!(text.spans[0].style.bold);
}
#[test]
fn test_parse_multiple_styles() {
let text = parse("[bold red]Hello[/]");
assert!(text.spans[0].style.bold);
assert_eq!(text.spans[0].style.foreground, Some(Color::Red));
}
#[test]
fn test_parse_nested() {
let text = parse("[bold]Hello [italic]World[/][/]");
assert_eq!(text.plain_text(), "Hello World");
assert!(text.spans[0].style.bold);
assert!(text.spans[1].style.bold);
assert!(text.spans[1].style.italic);
}
#[test]
fn test_parse_background() {
let text = parse("[white on red]Alert[/]");
assert_eq!(text.spans[0].style.foreground, Some(Color::White));
assert_eq!(text.spans[0].style.background, Some(Color::Red));
}
#[test]
fn test_parse_hyperlink() {
let text = parse("[link=https://example.com]Click here[/]");
assert_eq!(text.plain_text(), "Click here");
assert_eq!(text.spans[0].link, Some("https://example.com".to_string()));
}
#[test]
fn test_parse_hyperlink_with_style() {
let text = parse("[bold blue link=https://google.com]Google[/]");
assert_eq!(text.plain_text(), "Google");
assert!(text.spans[0].style.bold);
assert_eq!(text.spans[0].style.foreground, Some(Color::Blue));
assert_eq!(text.spans[0].link, Some("https://google.com".to_string()));
}
}