use std::fmt;
use regex::Regex;
use std::sync::LazyLock;
use crate::errors::MarkupError;
use crate::style::Style;
use crate::text::{Span, Text};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tag {
pub name: String,
pub parameters: Option<String>,
}
impl Tag {
pub fn markup(&self) -> String {
match &self.parameters {
Some(params) => format!("[{}={}]", self.name, params),
None => format!("[{}]", self.name),
}
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.parameters {
Some(params) => write!(f, "{} {}", self.name, params),
None => write!(f, "{}", self.name),
}
}
}
static RE_ESCAPE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\\*)(\[[a-z#/@][^\[]*?\])").unwrap());
static RE_MARKUP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\\*)(\[[a-z#/@][^\[]*?\])").unwrap());
pub fn escape(markup: &str) -> String {
let result = RE_ESCAPE.replace_all(markup, |caps: ®ex::Captures| {
let bs = &caps[1];
let tag = &caps[2];
format!("{}{}\\{}", bs, bs, tag)
});
let s = result.into_owned();
if s.ends_with('\\') && !s.ends_with("\\\\") {
format!("{}\\", s)
} else {
s
}
}
pub type MarkupElement = (usize, Option<String>, Option<Tag>);
pub fn parse_markup(markup: &str) -> Vec<MarkupElement> {
let mut elements: Vec<MarkupElement> = Vec::new();
let mut position: usize = 0;
for caps in RE_MARKUP.captures_iter(markup) {
let full_match = caps.get(0).unwrap();
let match_start = full_match.start();
if match_start > position {
let text = &markup[position..match_start];
elements.push((position, Some(text.to_string()), None));
}
let backslashes = &caps[1];
let tag_text = &caps[2];
let bs_count = backslashes.len();
if bs_count > 0 {
let literal_bs: String = "\\".repeat(bs_count / 2);
if bs_count % 2 == 0 {
if !literal_bs.is_empty() {
elements.push((match_start, Some(literal_bs), None));
}
let inner = &tag_text[1..tag_text.len() - 1]; let tag = parse_tag_inner(inner);
elements.push((match_start + bs_count, None, Some(tag)));
} else {
let escaped = format!("{}{}", literal_bs, tag_text);
elements.push((match_start, Some(escaped), None));
}
} else {
let inner = &tag_text[1..tag_text.len() - 1];
let tag = parse_tag_inner(inner);
elements.push((match_start, None, Some(tag)));
}
position = full_match.end();
}
if position < markup.len() {
let text = &markup[position..];
elements.push((position, Some(text.to_string()), None));
}
elements
}
fn parse_tag_inner(inner: &str) -> Tag {
if let Some(eq_pos) = inner.find('=') {
Tag {
name: inner[..eq_pos].to_string(),
parameters: Some(inner[eq_pos + 1..].to_string()),
}
} else {
Tag {
name: inner.to_string(),
parameters: None,
}
}
}
pub fn render(markup: &str, style: Style) -> Result<Text, MarkupError> {
if !markup.contains('[') {
return Ok(Text::new(markup, style));
}
let mut text = Text::new("", style);
let mut style_stack: Vec<(usize, Tag)> = Vec::new();
let elements = parse_markup(markup);
for (position, plain_text, tag) in &elements {
if let Some(plain) = plain_text {
let unescaped = plain.replace("\\[", "[");
text.append_str(&unescaped, None);
} else if let Some(tag) = tag {
if tag.name.starts_with('/') {
let style_name = &tag.name[1..];
if style_name.is_empty() {
if let Some((start, open_tag)) = style_stack.pop() {
if !open_tag.name.starts_with('@') {
let tag_style = resolve_tag_style(&open_tag);
let end = text.len();
if end > start {
text.spans_mut().push(Span::new(start, end, tag_style));
}
}
} else {
return Err(MarkupError::NothingToClose {
position: *position,
});
}
} else {
let normalized = style_name.to_lowercase();
let normalized = normalized.trim();
let found = style_stack
.iter()
.rposition(|(_, t)| t.name.to_lowercase().trim() == normalized);
if let Some(idx) = found {
let (start, open_tag) = style_stack.remove(idx);
if !open_tag.name.starts_with('@') {
let tag_style = resolve_tag_style(&open_tag);
let end = text.len();
if end > start {
text.spans_mut().push(Span::new(start, end, tag_style));
}
}
} else {
return Err(MarkupError::MismatchedTag {
tag: tag.name.clone(),
position: *position,
});
}
}
} else {
let normalized_name = tag.name.to_lowercase();
let normalized_name = normalized_name.trim().to_string();
let open_tag = Tag {
name: normalized_name,
parameters: tag.parameters.clone(),
};
let current_len = text.len();
style_stack.push((current_len, open_tag));
}
}
}
for (start, open_tag) in style_stack.into_iter().rev() {
if !open_tag.name.starts_with('@') {
let tag_style = resolve_tag_style(&open_tag);
let end = text.len();
if end > start {
text.spans_mut().push(Span::new(start, end, tag_style));
}
}
}
text.spans_mut().sort_by_key(|s| s.start);
Ok(text)
}
fn resolve_tag_style(tag: &Tag) -> Style {
let tag_str = tag.to_string();
Style::parse(&tag_str).unwrap_or_else(|_| {
Style::null()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_basic_tag() {
assert_eq!(escape("foo[bar]"), r"foo\[bar]");
}
#[test]
fn test_escape_already_escaped() {
assert_eq!(escape(r"foo\[bar]"), r"foo\\\[bar]");
}
#[test]
fn test_escape_not_a_tag() {
assert_eq!(escape("[5]"), "[5]");
}
#[test]
fn test_escape_at_tag() {
assert_eq!(escape("[@foo]"), r"\[@foo]");
}
#[test]
fn test_escape_backslash_end() {
assert_eq!(escape(r"C:\"), r"C:\\");
}
#[test]
fn test_tag_display_no_params() {
let tag = Tag {
name: "bold".to_string(),
parameters: None,
};
assert_eq!(tag.to_string(), "bold");
}
#[test]
fn test_tag_display_with_params() {
let tag = Tag {
name: "link".to_string(),
parameters: Some("https://example.com".to_string()),
};
assert_eq!(tag.to_string(), "link https://example.com");
}
#[test]
fn test_tag_markup_no_params() {
let tag = Tag {
name: "bold".to_string(),
parameters: None,
};
assert_eq!(tag.markup(), "[bold]");
}
#[test]
fn test_tag_markup_with_params() {
let tag = Tag {
name: "link".to_string(),
parameters: Some("url".to_string()),
};
assert_eq!(tag.markup(), "[link=url]");
}
#[test]
fn test_parse_basic() {
let elements = parse_markup("[foo]hello[/foo]");
assert_eq!(elements.len(), 3);
assert_eq!(elements[0].1, None);
assert_eq!(
elements[0].2,
Some(Tag {
name: "foo".to_string(),
parameters: None,
})
);
assert_eq!(elements[1].1, Some("hello".to_string()));
assert_eq!(elements[1].2, None);
assert_eq!(elements[2].1, None);
assert_eq!(
elements[2].2,
Some(Tag {
name: "/foo".to_string(),
parameters: None,
})
);
}
#[test]
fn test_parse_with_params() {
let elements = parse_markup("[link=https://example.com]click[/link]");
let tag = elements[0].2.as_ref().unwrap();
assert_eq!(tag.name, "link");
assert_eq!(tag.parameters, Some("https://example.com".to_string()));
}
#[test]
fn test_parse_plain_only() {
let elements = parse_markup("hello world");
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].1, Some("hello world".to_string()));
}
#[test]
fn test_render_basic() {
let result = render("[bold]FOO[/bold]", Style::null()).unwrap();
assert_eq!(result.plain(), "FOO");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].start, 0);
assert_eq!(result.spans()[0].end, 3);
assert_eq!(result.spans()[0].style, Style::parse("bold").unwrap());
}
#[test]
fn test_render_not_tags() {
let result = render("[[1], [1,2,3,4]]", Style::null()).unwrap();
assert_eq!(result.plain(), "[[1], [1,2,3,4]]");
}
#[test]
fn test_render_combine() {
let result = render("[green]X[blue]Y[/blue]Z[/green]", Style::null()).unwrap();
assert_eq!(result.plain(), "XYZ");
assert_eq!(result.spans().len(), 2);
assert_eq!(result.spans()[0].start, 0);
assert_eq!(result.spans()[0].end, 3);
assert_eq!(result.spans()[0].style, Style::parse("green").unwrap());
assert_eq!(result.spans()[1].start, 1);
assert_eq!(result.spans()[1].end, 2);
assert_eq!(result.spans()[1].style, Style::parse("blue").unwrap());
}
#[test]
fn test_render_overlap() {
let result = render("[green]X[bold]Y[/green]Z[/bold]", Style::null()).unwrap();
assert_eq!(result.plain(), "XYZ");
assert_eq!(result.spans().len(), 2);
assert_eq!(result.spans()[0].start, 0);
assert_eq!(result.spans()[0].end, 2);
assert_eq!(result.spans()[0].style, Style::parse("green").unwrap());
assert_eq!(result.spans()[1].start, 1);
assert_eq!(result.spans()[1].end, 3);
assert_eq!(result.spans()[1].style, Style::parse("bold").unwrap());
}
#[test]
fn test_render_implicit_close() {
let result = render("[bold]X[/]Y", Style::null()).unwrap();
assert_eq!(result.plain(), "XY");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].start, 0);
assert_eq!(result.spans()[0].end, 1);
assert_eq!(result.spans()[0].style, Style::parse("bold").unwrap());
}
#[test]
fn test_render_close_ambiguous() {
let result = render("[green]X[bold]Y[/]Z[/]", Style::null()).unwrap();
assert_eq!(result.plain(), "XYZ");
assert_eq!(result.spans().len(), 2);
assert_eq!(result.spans()[0].start, 0);
assert_eq!(result.spans()[0].end, 3);
assert_eq!(result.spans()[0].style, Style::parse("green").unwrap());
assert_eq!(result.spans()[1].start, 1);
assert_eq!(result.spans()[1].end, 2);
assert_eq!(result.spans()[1].style, Style::parse("bold").unwrap());
}
#[test]
fn test_markup_error_nothing_to_close() {
let result = render("foo[/]", Style::null());
assert!(result.is_err());
}
#[test]
fn test_markup_error_mismatched_explicit() {
let result = render("foo[/bar]", Style::null());
assert!(result.is_err());
}
#[test]
fn test_markup_error_mismatched_tags() {
let result = render("[foo]hello[/bar]", Style::null());
assert!(result.is_err());
}
#[test]
fn test_escape_escape_double_backslash() {
let result = render(r"\\[bold]FOO", Style::null()).unwrap();
assert_eq!(result.plain(), r"\FOO");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].start, 1);
assert_eq!(result.spans()[0].end, 4);
}
#[test]
fn test_escape_escape_single_backslash() {
let result = render(r"\[bold]FOO", Style::null()).unwrap();
assert_eq!(result.plain(), "[bold]FOO");
assert_eq!(result.spans().len(), 0);
}
#[test]
fn test_render_link() {
let result = render("[link=foo]FOO[/link]", Style::null()).unwrap();
assert_eq!(result.plain(), "FOO");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].style, Style::parse("link foo").unwrap());
}
#[test]
fn test_render_no_markup() {
let result = render("hello world", Style::null()).unwrap();
assert_eq!(result.plain(), "hello world");
assert_eq!(result.spans().len(), 0);
}
#[test]
fn test_render_unclosed_tags() {
let result = render("[bold]hello", Style::null()).unwrap();
assert_eq!(result.plain(), "hello");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].start, 0);
assert_eq!(result.spans()[0].end, 5);
assert_eq!(result.spans()[0].style, Style::parse("bold").unwrap());
}
#[test]
fn test_render_empty_markup() {
let result = render("", Style::null()).unwrap();
assert_eq!(result.plain(), "");
assert_eq!(result.spans().len(), 0);
}
#[test]
fn test_render_with_base_style() {
let base = Style::parse("italic").unwrap();
let result = render("[bold]hello[/bold]", base.clone()).unwrap();
assert_eq!(result.plain(), "hello");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].style, Style::parse("bold").unwrap());
}
#[test]
fn test_render_at_event_tag() {
let result = render("[@click]hello[/]", Style::null()).unwrap();
assert_eq!(result.plain(), "hello");
assert_eq!(result.spans().len(), 0);
}
#[test]
fn test_render_nested_same_style() {
let result = render("[bold][bold]X[/bold][/bold]", Style::null()).unwrap();
assert_eq!(result.plain(), "X");
assert_eq!(result.spans().len(), 2);
}
#[test]
fn test_render_theme_name_fallback() {
let result = render("[repr.number]42[/repr.number]", Style::null()).unwrap();
assert_eq!(result.plain(), "42");
assert_eq!(result.spans().len(), 1);
}
#[test]
fn test_parse_markup_escaped_tag() {
let elements = parse_markup(r"\[bold]");
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].1, Some("[bold]".to_string()));
assert_eq!(elements[0].2, None);
}
#[test]
fn test_render_link_url() {
let result = render("[link=https://example.com]click here[/link]", Style::null()).unwrap();
assert_eq!(result.plain(), "click here");
assert_eq!(result.spans().len(), 1);
let span_style = &result.spans()[0].style;
assert_eq!(span_style.link(), Some("https://example.com"));
}
#[test]
fn test_render_link_with_style() {
let result = render(
"[bold][link=https://example.com]click[/link][/bold]",
Style::null(),
)
.unwrap();
assert_eq!(result.plain(), "click");
assert_eq!(result.spans().len(), 2);
let has_link = result
.spans()
.iter()
.any(|s| s.style.link() == Some("https://example.com"));
let has_bold = result.spans().iter().any(|s| s.style.bold() == Some(true));
assert!(has_link);
assert!(has_bold);
}
#[test]
fn test_render_link_implicit_close() {
let result = render("[link=https://example.com]click[/]", Style::null()).unwrap();
assert_eq!(result.plain(), "click");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].style.link(), Some("https://example.com"));
}
}