use fancy_regex::Regex;
use crate::errors::{Result, RichError};
use crate::style::{Style, StyleType};
use crate::text::{Span, Text};
struct RawSpan {
start: usize,
end: usize,
name: String,
parameters: Option<String>,
}
fn is_tag_start(c: char) -> bool {
c.is_ascii_lowercase() || c == '#' || c == '/' || c == '@'
}
static RE_TAGS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"((\\*)\[([a-z#/@][^\[]*?)\])").expect("RE_TAGS is a valid pattern")
});
enum Event<'a> {
Text(String),
Tag {
name: &'a str,
parameters: Option<String>,
position: usize,
},
}
fn parse(markup: &str) -> Result<Vec<Event<'_>>> {
let mut events: Vec<Event> = Vec::new();
let mut position = 0usize;
for captures in RE_TAGS.captures_iter(markup) {
let captures =
captures.map_err(|e| RichError::Markup(format!("markup scan failed: {e}")))?;
let whole = captures.get(1).expect("group 1 always participates");
let escapes = captures.get(2).map_or("", |m| m.as_str());
let tag_text = captures
.get(3)
.expect("group 3 always participates")
.as_str();
let (mut start, end) = (whole.start(), whole.end());
if start > position {
events.push(Event::Text(unescape_brackets(&markup[position..start])));
}
if !escapes.is_empty() {
let (backslashes, escaped) = (escapes.len() / 2, escapes.len() % 2 == 1);
if backslashes > 0 {
events.push(Event::Text("\\".repeat(backslashes)));
start += backslashes * 2;
}
if escaped {
events.push(Event::Text(whole.as_str()[escapes.len()..].to_string()));
position = end;
continue;
}
}
let (name, parameters) = match tag_text.split_once('=') {
Some((name, params)) => (name, Some(params.to_string())),
None => (tag_text, None),
};
events.push(Event::Tag {
name,
parameters,
position: start,
});
position = end;
}
if position < markup.len() {
events.push(Event::Text(unescape_brackets(&markup[position..])));
}
Ok(events)
}
fn unescape_brackets(text: &str) -> String {
text.replace("\\[", "[")
}
fn push_plain(plain: &mut String, chunk: &str) {
if chunk.chars().any(crate::text::is_control_code) {
plain.extend(chunk.chars().filter(|c| !crate::text::is_control_code(*c)));
} else {
plain.push_str(chunk);
}
}
pub fn escape(markup: &str) -> String {
let bytes = markup.as_bytes();
let mut out = String::with_capacity(markup.len() + 2);
let mut i = 0;
while i < bytes.len() {
let run_start = i;
while i < bytes.len() && bytes[i] == b'\\' {
i += 1;
}
let backslashes = i - run_start;
let is_opener =
bytes.get(i) == Some(&b'[') && markup[i + 1..].chars().next().is_some_and(is_tag_start);
if is_opener {
for _ in 0..backslashes * 2 + 1 {
out.push('\\');
}
out.push('[');
i += 1;
} else {
for _ in 0..backslashes {
out.push('\\');
}
if let Some(c) = markup[i..].chars().next() {
out.push(c);
i += c.len_utf8();
}
}
}
if out.ends_with('\\') && !out.ends_with("\\\\") {
out.push('\\');
}
out
}
pub fn render(markup: &str) -> Result<Text> {
let mut plain = String::new();
let mut raw_spans: Vec<RawSpan> = Vec::new();
let mut stack: Vec<(String, Option<String>, usize)> = Vec::new();
for event in parse(markup)? {
match event {
Event::Text(chunk) => push_plain(&mut plain, &chunk),
Event::Tag {
name: tag_name,
parameters,
position: i,
} => {
if let Some(name) = tag_name.strip_prefix('/') {
let name = name.trim();
let end = plain.len();
let (open_name, open_parameters, start) = if name.is_empty() {
stack.pop().ok_or_else(|| {
RichError::Markup(format!(
"closing tag '[/]' at position {i} has nothing to close"
))
})?
} else {
let wanted = Style::normalize(name);
let pos = stack
.iter()
.rposition(|(open, _, _)| *open == wanted)
.ok_or_else(|| {
RichError::Markup(format!(
"closing tag '[/{name}]' at position {i} doesn't match any open tag"
))
})?;
stack.remove(pos)
};
raw_spans.push(RawSpan {
start,
end,
name: open_name,
parameters: open_parameters,
});
} else {
stack.push((Style::normalize(tag_name), parameters, plain.len()));
}
}
}
}
let end = plain.len();
while let Some((open_name, open_parameters, start)) = stack.pop() {
raw_spans.push(RawSpan {
start,
end,
name: open_name,
parameters: open_parameters,
});
}
let mut spans: Vec<Span> = Vec::with_capacity(raw_spans.len());
for raw in raw_spans {
let style = if raw.name.starts_with('@') {
StyleType::Style(Style::new())
} else {
StyleType::Name(match &raw.parameters {
Some(parameters) => format!("{} {}", raw.name, parameters),
None => raw.name.clone(),
})
};
spans.push(Span {
start: raw.start,
end: raw.end,
style,
});
}
spans.reverse();
spans.sort_by_key(|span| span.start);
let mut text = Text::new(plain);
for span in spans {
text.push_span(span);
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::ColorSystem;
use crate::theme::Theme;
fn render_to_ansi(markup: &str) -> String {
let text = render(markup).unwrap();
let segments = text.render(&Theme::default_theme(), &Style::new());
segments
.iter()
.map(|s| {
s.style
.clone()
.unwrap_or_default()
.render(&s.text, Some(ColorSystem::Truecolor))
})
.collect()
}
#[test]
fn simple_tags() {
assert_eq!(
render_to_ansi("[bold]Hello[/] [red]World[/]"),
"\x1b[1mHello\x1b[0m \x1b[31mWorld\x1b[0m"
);
}
#[test]
fn control_codes_never_desynchronise_span_offsets() {
let text = render("a\r[red]\u{2593}x[/]").expect("valid markup");
assert_eq!(text.plain(), "a\u{2593}x");
for span in text.spans() {
assert!(
text.plain().is_char_boundary(span.start)
&& text.plain().is_char_boundary(span.end),
"span {span:?} does not land on char boundaries of {:?}",
text.plain()
);
}
let rendered = render_to_ansi("a\r[red]\u{2593}x[/]");
assert!(rendered.contains('\u{2593}'), "got {rendered:?}");
}
#[test]
fn nested_inner_wins() {
let out = render_to_ansi("[red]a[blue]x[/]b[/]");
assert_eq!(out, "\x1b[31ma\x1b[0m\x1b[34mx\x1b[0m\x1b[31mb\x1b[0m");
}
#[test]
fn escaped_bracket_is_literal() {
let text = render("\\[not a tag]").unwrap();
assert_eq!(text.plain(), "[not a tag]");
}
#[test]
fn theme_name_resolves() {
assert_eq!(render_to_ansi("[repr.number]7[/]"), "\x1b[1;36m7\x1b[0m");
}
#[test]
fn none_is_the_null_style() {
assert!(Style::parse("none").unwrap().is_null());
assert!(Style::parse("").unwrap().is_null());
assert!(Style::parse("bold none").is_err());
}
#[test]
fn bracket_is_literal_unless_tag_start() {
assert_eq!(render("[Hello] world").unwrap().plain(), "[Hello] world");
assert_eq!(render("[42] x").unwrap().plain(), "[42] x");
}
#[test]
fn hex_tag_and_meta_tag() {
assert_eq!(
render_to_ansi("[#ff0000]x[/]"),
"\x1b[38;2;255;0;0mx\x1b[0m"
);
assert_eq!(render_to_ansi("[@foo]y[/]"), "y");
}
#[test]
fn unmatched_closing_tags_error() {
assert!(render("a[/]b").is_err());
assert!(render("[bold]a[/red]").is_err());
assert!(render("x[/red]y").is_err());
assert!(render("[bold]hi").is_ok());
}
#[test]
fn escape_matches_upstream() {
assert_eq!(escape("[bold]"), "\\[bold]");
assert_eq!(escape("a[b]c"), "a\\[b]c");
assert_eq!(escape("back\\slash"), "back\\slash");
assert_eq!(escape("trailing\\"), "trailing\\\\");
assert_eq!(escape("[Hello]"), "[Hello]"); assert_eq!(render(&escape("[bold]")).unwrap().plain(), "[bold]");
}
}