use regex::Regex;
use std::fmt;
use std::sync::LazyLock;
use crate::style::Style;
use crate::text::Text;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarkupError {
UnmatchedClosingTag(Option<String>),
InvalidTag(String),
}
impl fmt::Display for MarkupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnmatchedClosingTag(None) => {
write!(f, "closing tag '[/]' has nothing to close")
}
Self::UnmatchedClosingTag(Some(tag)) => {
write!(f, "closing tag '[/{tag}]' doesn't match any open tag")
}
Self::InvalidTag(msg) => write!(f, "invalid tag: {msg}"),
}
}
}
impl std::error::Error for MarkupError {}
#[derive(Debug, Clone)]
pub struct Tag {
pub name: String,
pub parameters: Option<String>,
}
impl Tag {
pub fn new(name: impl Into<String>, parameters: Option<String>) -> Self {
Self {
name: name.into(),
parameters,
}
}
#[must_use]
pub fn is_closing(&self) -> bool {
self.name.starts_with('/')
}
#[must_use]
pub fn base_name(&self) -> &str {
if self.is_closing() {
&self.name[1..]
} else {
&self.name
}
}
}
#[derive(Debug, Clone)]
pub enum ParseElement {
Text(String),
Tag(Tag),
}
static TAG_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\\*)\[([A-Za-z#/@][^\[\]]*?)\]").expect("invalid regex"));
fn parse_elements(markup: &str) -> Vec<(usize, Option<String>, Option<Tag>)> {
let mut elements = Vec::new();
let mut last_end = 0;
for cap in TAG_PATTERN.captures_iter(markup) {
let full_match = cap.get(0).unwrap();
let backslashes = cap.get(1).map_or("", |m| m.as_str());
let tag_content = cap.get(2).map_or("", |m| m.as_str());
let match_start = full_match.start();
if match_start > last_end {
let text = &markup[last_end..match_start];
elements.push((last_end, Some(text.to_string()), None));
}
let num_backslashes = backslashes.len();
let escaped = num_backslashes % 2 == 1;
if num_backslashes > 0 {
let literal_backslashes = num_backslashes / 2;
if literal_backslashes > 0 {
elements.push((match_start, Some("\\".repeat(literal_backslashes)), None));
}
}
if escaped {
elements.push((match_start, Some(format!("[{tag_content}]")), None));
} else {
let tag = parse_tag(tag_content);
elements.push((match_start, None, Some(tag)));
}
last_end = full_match.end();
}
if last_end < markup.len() {
elements.push((last_end, Some(markup[last_end..].to_string()), None));
}
elements
}
fn parse_tag(content: &str) -> Tag {
let trimmed = content.trim();
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim().to_string();
let param = trimmed[eq_pos + 1..].trim().to_string();
return Tag::new(name, Some(param));
}
if (trimmed.starts_with('@') || trimmed.starts_with("/@"))
&& let Some(paren_start) = trimmed.find('(')
&& let Some(paren_end) = trimmed.rfind(')')
&& paren_start < paren_end
{
let name = trimmed[..paren_start].to_string();
let param = trimmed[paren_start + 1..paren_end].to_string();
return Tag::new(name, Some(param));
}
Tag::new(trimmed, None)
}
pub fn render(markup: &str) -> Result<Text, MarkupError> {
render_with_style_resolver(markup, |definition| {
Style::parse(definition).unwrap_or_else(|_| Style::new())
})
}
pub fn render_with_style_resolver<F>(markup: &str, resolve_style: F) -> Result<Text, MarkupError>
where
F: Fn(&str) -> Style,
{
if !markup.contains('[') {
return Ok(Text::new(markup));
}
let mut text = Text::new("");
let mut style_stack: Vec<(usize, Tag)> = Vec::new();
for (_position, plain_text, tag) in parse_elements(markup) {
if let Some(plain) = plain_text {
let unescaped = plain.replace("\\[", "[");
text.append(&unescaped);
}
if let Some(tag) = tag {
if tag.is_closing() {
let style_name = tag.base_name().trim();
let (start, open_tag) = if style_name.is_empty() {
style_stack
.pop()
.ok_or(MarkupError::UnmatchedClosingTag(None))?
} else {
pop_matching(&mut style_stack, style_name).ok_or_else(|| {
MarkupError::UnmatchedClosingTag(Some(style_name.to_string()))
})?
};
let style = tag_to_style_with_resolver(&open_tag, &resolve_style);
let end = text.len();
if start < end {
text.stylize(start, end, style);
}
} else {
let normalized = Tag::new(Style::normalize(&tag.name), tag.parameters.clone());
style_stack.push((text.len(), normalized));
}
}
}
while let Some((start, tag)) = style_stack.pop() {
let style = tag_to_style_with_resolver(&tag, &resolve_style);
let end = text.len();
if start < end {
text.stylize(start, end, style);
}
}
Ok(text)
}
fn pop_matching(stack: &mut Vec<(usize, Tag)>, name: &str) -> Option<(usize, Tag)> {
let search_name = Style::normalize(name);
for i in (0..stack.len()).rev() {
if stack[i].1.name == search_name {
return Some(stack.remove(i));
}
}
None
}
fn tag_to_style_with_resolver<F>(tag: &Tag, resolve_style: &F) -> Style
where
F: Fn(&str) -> Style,
{
if tag.name.eq_ignore_ascii_case("link")
&& let Some(ref url) = tag.parameters
{
return Style::new().link(url);
}
resolve_style(&tag.name)
}
#[must_use]
pub fn escape(text: &str) -> String {
text.replace('[', "\\[")
}
#[must_use]
pub fn render_or_plain(markup: &str) -> Text {
render(markup).unwrap_or_else(|_| Text::new(markup))
}
#[must_use]
pub fn render_or_plain_with_style_resolver<F>(markup: &str, resolve_style: F) -> Text
where
F: Fn(&str) -> Style,
{
render_with_style_resolver(markup, resolve_style).unwrap_or_else(|_| Text::new(markup))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_plain() {
let text = render("hello world").unwrap();
assert_eq!(text.plain(), "hello world");
assert!(text.spans().is_empty());
}
#[test]
fn test_render_bold() {
let text = render("[bold]hello[/bold]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_implicit_close() {
let text = render("[bold]hello[/]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_nested() {
let text = render("[bold][red]hello[/red][/bold]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 2);
}
#[test]
fn test_render_multiple_styles() {
let text = render("[bold red]hello[/]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_normalizes_style_token_order() {
let text = render("[red bold]hello[/bold red]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_explicit_close_with_different_token_order() {
let text = render("[red bold]hello[/red bold]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_explicit_close_matches_full_style() {
let text = render("[bold red]hello[/bold red]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_escaped_bracket() {
let text = render("\\[not a tag]").unwrap();
assert_eq!(text.plain(), "[not a tag]");
}
#[test]
fn test_render_unclosed_tag() {
let text = render("[bold]hello").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1); }
#[test]
fn test_render_mixed() {
let text = render("hello [bold]world[/]!").unwrap();
assert_eq!(text.plain(), "hello world!");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_escape() {
assert_eq!(escape("hello [world]"), "hello \\[world]");
}
#[test]
fn test_unmatched_closing_tag() {
let result = render("[/bold]");
let err = result.expect_err("expected error for unmatched closing tag");
assert_eq!(
err.to_string(),
"closing tag '[/bold]' doesn't match any open tag"
);
}
#[test]
fn test_explicit_close_requires_full_tag_match() {
let result = render("[bold red]text[/bold]");
let err = result.expect_err("expected error for mismatched closing tag");
assert_eq!(
err.to_string(),
"closing tag '[/bold]' doesn't match any open tag"
);
}
#[test]
fn test_empty_close_nothing_to_close() {
let result = render("hello[/]");
let err = result.expect_err("expected error for empty close");
assert_eq!(err.to_string(), "closing tag '[/]' has nothing to close");
}
#[test]
fn test_no_brackets_optimization() {
let text = render("plain text without any brackets").unwrap();
assert_eq!(text.plain(), "plain text without any brackets");
}
#[test]
fn test_link_tag() {
let text = render("[link=https://example.com]click here[/link]").unwrap();
assert_eq!(text.plain(), "click here");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_tag_parsing() {
let tag = parse_tag("bold red");
assert_eq!(tag.name, "bold red");
assert!(tag.parameters.is_none());
let tag = parse_tag("link=https://example.com");
assert_eq!(tag.name, "link");
assert_eq!(tag.parameters, Some("https://example.com".to_string()));
}
#[test]
fn test_render_color() {
let text = render("[red]error[/] [green]success[/]").unwrap();
assert_eq!(text.plain(), "error success");
assert_eq!(text.spans().len(), 2);
}
#[test]
fn test_render_uppercase_tag() {
let text = render("[BOLD]hello[/BOLD]").unwrap();
assert_eq!(text.plain(), "hello");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_deep_nesting_3_levels() {
let text = render("[bold][italic][underline]deep[/][/][/]").unwrap();
assert_eq!(text.plain(), "deep");
assert_eq!(text.spans().len(), 3);
}
#[test]
fn test_deep_nesting_4_levels() {
let text = render("[bold][red][italic][underline]very deep[/][/][/][/]").unwrap();
assert_eq!(text.plain(), "very deep");
assert_eq!(text.spans().len(), 4);
}
#[test]
fn test_nested_with_explicit_close() {
let text = render("[bold][italic]text[/italic][/bold]").unwrap();
assert_eq!(text.plain(), "text");
assert_eq!(text.spans().len(), 2);
}
#[test]
fn test_sibling_tags() {
let text = render("[bold]one[/][italic]two[/][underline]three[/]").unwrap();
assert_eq!(text.plain(), "onetwothree");
assert_eq!(text.spans().len(), 3);
}
#[test]
fn test_sibling_tags_with_text_between() {
let text = render("[bold]one[/] and [italic]two[/] and [red]three[/]").unwrap();
assert_eq!(text.plain(), "one and two and three");
assert_eq!(text.spans().len(), 3);
}
#[test]
fn test_style_combination_bold_red() {
let text = render("[bold red]styled[/]").unwrap();
assert_eq!(text.plain(), "styled");
assert_eq!(text.spans().len(), 1);
let style = &text.spans()[0].style;
assert!(style.attributes.contains(crate::style::Attributes::BOLD));
assert!(style.color.is_some());
}
#[test]
fn test_style_on_background() {
let text = render("[red on blue]text[/]").unwrap();
assert_eq!(text.plain(), "text");
assert_eq!(text.spans().len(), 1);
let style = &text.spans()[0].style;
assert!(style.color.is_some());
assert!(style.bgcolor.is_some());
}
#[test]
fn test_double_backslash() {
let text = render("\\\\[bold]text[/]").unwrap();
assert!(text.plain().contains('\\'));
assert!(text.plain().contains("text"));
}
#[test]
fn test_mixed_escaped_and_tags() {
let text = render("\\[not tag] [bold]real tag[/]").unwrap();
assert_eq!(text.plain(), "[not tag] real tag");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_escaped_bracket_in_middle() {
let text = render("[bold]hello\\[world[/]").unwrap();
assert!(text.plain().contains("hello"));
}
#[test]
fn test_empty_string() {
let text = render("").unwrap();
assert_eq!(text.plain(), "");
assert!(text.spans().is_empty());
}
#[test]
fn test_only_whitespace() {
let text = render(" ").unwrap();
assert_eq!(text.plain(), " ");
}
#[test]
fn test_empty_tag_content() {
let text = render("[]").unwrap();
assert_eq!(text.plain(), "[]");
}
#[test]
fn test_unclosed_bracket() {
let text = render("[bold without closing").unwrap();
assert_eq!(text.plain(), "[bold without closing");
}
#[test]
fn test_unopened_bracket() {
let text = render("text] more").unwrap();
assert_eq!(text.plain(), "text] more");
}
#[test]
fn test_invalid_style_graceful() {
let text = render("[invalidstyle12345]text[/]").unwrap();
assert_eq!(text.plain(), "text");
}
#[test]
fn test_nested_brackets_in_content() {
let text = render("[bold]hello \\[world\\][/]").unwrap();
assert!(text.plain().contains("hello"));
}
#[test]
fn test_no_panic_on_random_brackets() {
let inputs = [
"[[[]]]",
"[[[",
"]]]",
"[/][/][/]",
"\\\\\\\\",
"[bold[italic]text[/]",
"[=value]text[/]",
"[@handler]text[/]",
];
for input in inputs {
let _ = render(input);
}
}
#[test]
fn test_no_panic_unicode() {
let text = render("[bold]日本語テã‚スト[/]").unwrap();
assert_eq!(text.plain(), "日本語テã‚スト");
}
#[test]
fn test_no_panic_emoji() {
let text = render("[red]🎉 celebration 🎉[/]").unwrap();
assert_eq!(text.plain(), "🎉 celebration 🎉");
}
#[test]
fn test_link_with_special_chars() {
let text = render("[link=https://example.com/path?a=1&b=2]url[/link]").unwrap();
assert_eq!(text.plain(), "url");
}
#[test]
fn test_handler_syntax() {
let tag = parse_tag("@click(button1)");
assert_eq!(tag.name, "@click");
assert_eq!(tag.parameters, Some("button1".to_string()));
}
#[test]
fn test_handler_syntax_malformed_parens() {
let tag = parse_tag("@)(");
assert_eq!(tag.name, "@)(");
assert!(tag.parameters.is_none());
let tag2 = parse_tag("@handler)(args");
assert_eq!(tag2.name, "@handler)(args");
assert!(tag2.parameters.is_none());
}
#[test]
fn test_adjacent_tags_no_space() {
let text = render("[bold]A[/][italic]B[/][underline]C[/]").unwrap();
assert_eq!(text.plain(), "ABC");
assert_eq!(text.spans().len(), 3);
}
#[test]
fn test_many_tags_single_line() {
let markup = "[red]R[/][green]G[/][blue]B[/][yellow]Y[/][magenta]M[/][cyan]C[/]";
let text = render(markup).unwrap();
assert_eq!(text.plain(), "RGBYMC");
assert_eq!(text.spans().len(), 6);
}
#[test]
fn test_tag_with_internal_whitespace() {
let text = render("[bold red]styled[/]").unwrap();
assert_eq!(text.plain(), "styled");
}
#[test]
fn test_tag_trimming() {
let tag = parse_tag(" bold ");
assert_eq!(tag.name, "bold");
}
#[test]
fn test_interleaved_text_and_tags() {
let text = render("start [bold]middle[/] end").unwrap();
assert_eq!(text.plain(), "start middle end");
}
#[test]
fn test_color_hex() {
let text = render("[#ff0000]red hex[/]").unwrap();
assert_eq!(text.plain(), "red hex");
assert_eq!(text.spans().len(), 1);
}
#[test]
fn test_render_or_plain_fallback() {
let text = render_or_plain("[/]");
assert_eq!(text.plain(), "[/]"); }
#[test]
fn test_tag_is_closing() {
let open = Tag::new("bold", None);
let close = Tag::new("/bold", None);
assert!(!open.is_closing());
assert!(close.is_closing());
}
#[test]
fn test_tag_base_name() {
let tag = Tag::new("/bold", None);
assert_eq!(tag.base_name(), "bold");
let tag = Tag::new("bold", None);
assert_eq!(tag.base_name(), "bold");
}
}