use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use regex::Regex;
use std::sync::LazyLock;
use crate::error::MarkupError;
use crate::style::Style;
use crate::text::{Span, Text};
use crate::utils::emoji_replace::emoji_replace;
#[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_MARKUP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\\*)(\[[a-z#/@][^\[]*?\])").unwrap());
pub fn escape(markup: &str) -> String {
let result = RE_MARKUP.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('[') {
let replaced = emoji_replace(markup, None);
return Ok(Text::new(replaced.as_ref(), 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 with_emoji = emoji_replace(plain, None);
text.append_str(with_emoji.as_ref(), 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 end = text.len();
if end > start {
let meta_arc = parse_meta_tag(&open_tag);
text.spans_mut().push(Span::with_meta(
start,
end,
Style::null(),
Some(meta_arc),
));
}
} else {
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.as_str() == normalized);
if let Some(idx) = found {
let (start, open_tag) = style_stack.remove(idx);
if open_tag.name.starts_with('@') {
let end = text.len();
if end > start {
let meta_arc = parse_meta_tag(&open_tag);
text.spans_mut().push(Span::with_meta(
start,
end,
Style::null(),
Some(meta_arc),
));
}
} else {
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.trim().to_lowercase();
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() {
let end = text.len();
if end > start {
if open_tag.name.starts_with('@') {
let meta_arc = parse_meta_tag(&open_tag);
text.spans_mut()
.push(Span::with_meta(start, end, Style::null(), Some(meta_arc)));
} else {
let tag_style = resolve_tag_style(&open_tag);
text.spans_mut().push(Span::new(start, end, tag_style));
}
}
}
text.spans_mut().sort_by_key(|s| s.start);
Ok(text)
}
fn parse_meta_tag(tag: &Tag) -> Arc<HashMap<String, String>> {
let key = tag.name.trim_start_matches('@').trim().to_string();
let raw_value = match &tag.parameters {
Some(v) => {
let v = v.trim();
if (v.starts_with('"') && v.ends_with('"'))
|| (v.starts_with('\'') && v.ends_with('\''))
{
v[1..v.len() - 1].to_string()
} else {
v.to_string()
}
}
None => "true".to_string(),
};
let mut map = HashMap::new();
map.insert(key, raw_value);
Arc::new(map)
}
fn resolve_tag_style(tag: &Tag) -> Style {
let tag_str = tag.to_string();
Style::parse_strict(&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"));
}
#[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"));
assert_eq!(result.spans()[1].start, 1);
assert_eq!(result.spans()[1].end, 2);
assert_eq!(result.spans()[1].style, Style::parse("blue"));
}
#[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"));
assert_eq!(result.spans()[1].start, 1);
assert_eq!(result.spans()[1].end, 3);
assert_eq!(result.spans()[1].style, Style::parse("bold"));
}
#[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"));
}
#[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"));
assert_eq!(result.spans()[1].start, 1);
assert_eq!(result.spans()[1].end, 2);
assert_eq!(result.spans()[1].style, Style::parse("bold"));
}
#[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"));
}
#[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"));
}
#[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");
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"));
}
#[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(), 1);
let span = &result.spans()[0];
assert_eq!(span.start, 0);
assert_eq!(span.end, 5);
assert!(span.style.is_null());
let meta = span.meta.as_ref().expect("meta span must have metadata");
assert_eq!(meta.get("click").map(|v| v.as_str()), Some("true"));
}
#[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"));
}
#[test]
fn test_render_meta_key_value() {
let result = render("[@key=val]x[/]", Style::null()).unwrap();
assert_eq!(result.plain(), "x");
assert_eq!(result.spans().len(), 1);
let span = &result.spans()[0];
assert_eq!(span.start, 0);
assert_eq!(span.end, 1);
assert!(span.style.is_null(), "meta span must have null style");
let meta = span.meta.as_ref().expect("must have meta");
assert_eq!(meta.get("key").map(|v| v.as_str()), Some("val"));
}
#[test]
fn test_render_meta_bare_flag() {
let result = render("[@flag]text[/]", Style::null()).unwrap();
assert_eq!(result.plain(), "text");
assert_eq!(result.spans().len(), 1);
let meta = result.spans()[0].meta.as_ref().expect("must have meta");
assert_eq!(meta.get("flag").map(|v| v.as_str()), Some("true"));
}
#[test]
fn test_render_meta_quoted_value() {
let result = render(r#"[@label="hello world"]x[/]"#, Style::null()).unwrap();
assert_eq!(result.plain(), "x");
let meta = result.spans()[0].meta.as_ref().expect("must have meta");
assert_eq!(meta.get("label").map(|v| v.as_str()), Some("hello world"));
}
#[test]
fn test_render_meta_explicit_close() {
let result = render("[@mykey=42]text[/@mykey]", Style::null()).unwrap();
assert_eq!(result.plain(), "text");
assert_eq!(result.spans().len(), 1);
let meta = result.spans()[0].meta.as_ref().expect("must have meta");
assert_eq!(meta.get("mykey").map(|v| v.as_str()), Some("42"));
}
#[test]
fn test_render_meta_visible_text_only() {
let result = render("before[@k=v]inside[/]after", Style::null()).unwrap();
assert_eq!(result.plain(), "beforeinsideafter");
let meta_spans: Vec<_> = result.spans().iter().filter(|s| s.meta.is_some()).collect();
assert_eq!(meta_spans.len(), 1);
assert_eq!(meta_spans[0].start, 6);
assert_eq!(meta_spans[0].end, 12);
}
#[test]
fn test_render_escaped_bracket_no_corruption() {
let result = render(r"\[bar]", Style::null()).unwrap();
assert_eq!(result.plain(), "[bar]");
assert_eq!(
result.spans().len(),
0,
"escaped tag must not produce a span"
);
}
#[test]
fn test_render_mixed_escaped_and_real_tag() {
let result = render(r"foo \[bar] [bold]baz[/bold]", Style::null()).unwrap();
assert_eq!(result.plain(), "foo [bar] baz");
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].style, Style::parse("bold"));
assert_eq!(result.spans()[0].start, 10);
assert_eq!(result.spans()[0].end, 13);
}
#[test]
fn test_render_emoji_fast_path() {
let result = render("Hello :heart:!", Style::null()).unwrap();
assert!(
result.plain().contains('\u{2764}'),
"expected heart emoji in fast path, got {:?}",
result.plain()
);
assert!(!result.plain().contains(":heart:"));
}
#[test]
fn test_render_emoji_full_path() {
let result = render("[bold]:smile:[/bold]", Style::null()).unwrap();
assert!(
!result.plain().contains(":smile:"),
"emoji shortcode must be expanded in full parse path, got {:?}",
result.plain()
);
assert_eq!(result.spans().len(), 1);
assert_eq!(result.spans()[0].style, Style::parse("bold"));
}
#[test]
fn test_render_emoji_mixed_with_markup() {
let result = render(":heart: [bold]world[/bold]", Style::null()).unwrap();
assert!(result.plain().contains('\u{2764}'));
assert!(result.plain().contains("world"));
assert!(!result.plain().contains(":heart:"));
}
}