use fancy_regex::Regex;
use crate::errors::{Result, RichError};
use crate::style::{Meta, MetaValue, Style, StyleType};
use crate::text::{Span, Text};
struct RawSpan {
start: usize,
end: usize,
style: StyleType,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tag {
pub name: String,
pub parameters: Option<String>,
}
impl Tag {
pub fn new(name: impl Into<String>, parameters: Option<String>) -> Self {
Tag {
name: name.into(),
parameters,
}
}
pub fn markup(&self) -> String {
match &self.parameters {
None => format!("[{}]", self.name),
Some(parameters) => format!("[{}={}]", self.name, parameters),
}
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.parameters {
None => f.write_str(&self.name),
Some(parameters) => write!(f, "{} {}", self.name, parameters),
}
}
}
pub(crate) fn py_repr_str(value: &str) -> String {
let quote = if value.contains('\'') && !value.contains('"') {
'"'
} else {
'\''
};
let mut out = String::with_capacity(value.len() + 2);
out.push(quote);
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if ch == quote => {
out.push('\\');
out.push(ch);
}
ch if (ch as u32) < 0x20 || ch as u32 == 0x7f => {
out.push_str(&format!("\\x{:02x}", ch as u32));
}
ch => out.push(ch),
}
}
out.push(quote);
out
}
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> {
render_inner(markup, false, &StyleType::default())
}
pub fn render_with(
markup: &str,
style: impl Into<StyleType>,
emoji: bool,
emoji_variant: Option<crate::emoji::EmojiVariant>,
) -> Result<Text> {
let style = style.into();
if !markup.contains('[') {
let plain = if emoji {
crate::emoji::replace_with_variant(markup, emoji_variant)
} else {
markup.to_string()
};
return Ok(Text::styled(plain, style));
}
render_inner(markup, emoji, &style)
}
pub fn render_emoji(markup: &str) -> Result<Text> {
render_inner(markup, true, &StyleType::default())
}
fn match_handler(parameters: &str) -> Option<(&str, Option<&str>)> {
let is_name = |c: char| c.is_alphanumeric() || c == '_' || c == '.';
if parameters.chars().all(is_name) {
return Some((parameters, None));
}
let open = parameters.find(|c: char| !is_name(c))?;
let (name, rest) = parameters.split_at(open);
if rest.starts_with('(') && rest.ends_with(')') && rest.len() >= 2 && !rest.contains('\n') {
return Some((name, Some(rest)));
}
None
}
fn handler_meta(name: &str, parameters: Option<&str>) -> Result<Meta> {
let mut meta = Meta::new();
let Some(raw) = parameters.filter(|parameters| !parameters.is_empty()) else {
meta.insert(name, MetaValue::Tuple(Vec::new()));
return Ok(meta);
};
let mut parameters = raw.trim();
let mut handler_name = "";
if let Some((handler, arguments)) = match_handler(parameters) {
handler_name = handler;
parameters = arguments.unwrap_or("()");
}
let value = literal_eval(parameters).map_err(|error| {
RichError::Markup(match error {
LiteralError::Syntax(message) => format!(
"error parsing {} in {}; {message}",
py_repr_str(parameters),
py_repr_str(raw)
),
LiteralError::Value(message) => {
format!("error parsing {}; {message}", py_repr_str(raw))
}
})
})?;
let value = if handler_name.is_empty() {
value
} else {
let arguments = match value {
MetaValue::Tuple(items) => MetaValue::Tuple(items),
other => MetaValue::Tuple(vec![other]),
};
MetaValue::Tuple(vec![MetaValue::Str(handler_name.to_string()), arguments])
};
meta.insert(name, value);
Ok(meta)
}
#[derive(Debug, PartialEq)]
enum LiteralError {
Syntax(String),
Value(String),
}
fn literal_eval(source: &str) -> std::result::Result<MetaValue, LiteralError> {
tokenize_check(source)?;
let mut parser = LiteralParser {
chars: source
.trim_matches(|c| c == ' ' || c == '\t')
.chars()
.collect(),
position: 0,
};
let value = parser.expression()?;
parser.skip_space();
if parser.position < parser.chars.len() {
return Err(parser.trailing());
}
Ok(value)
}
fn tokenize_check(source: &str) -> std::result::Result<(), LiteralError> {
let syntax = |message: String| Err(LiteralError::Syntax(message));
let mut open: Vec<char> = Vec::new();
let mut chars = source.chars();
while let Some(c) = chars.next() {
match c {
'\'' | '"' => loop {
match chars.next() {
None => {
return syntax(
"unterminated string literal (detected at line 1)".to_string(),
)
}
Some('\\') => {
chars.next();
}
Some(end) if end == c => break,
Some(_) => {}
}
},
'(' | '[' | '{' => open.push(c),
')' | ']' | '}' => match open.pop() {
None => return syntax(format!("unmatched '{c}'")),
Some(opener) => {
let expected = match opener {
'(' => ')',
'[' => ']',
_ => '}',
};
if c != expected {
return syntax(format!(
"closing parenthesis '{c}' does not match opening parenthesis '{opener}'"
));
}
}
},
_ => {}
}
}
match open.last() {
Some(opener) => syntax(format!("'{opener}' was never closed")),
None => Ok(()),
}
}
struct LiteralParser {
chars: Vec<char>,
position: usize,
}
impl LiteralParser {
fn peek(&self) -> Option<char> {
self.chars.get(self.position).copied()
}
fn skip_space(&mut self) {
while matches!(self.peek(), Some(' ' | '\t')) {
self.position += 1;
}
}
fn malformed() -> LiteralError {
LiteralError::Value("malformed node or string".to_string())
}
fn trailing(&self) -> LiteralError {
match self.peek() {
Some(c) if "+-*/%&|^<>=.@[(".contains(c) => Self::malformed(),
Some(')') => LiteralError::Syntax("unmatched ')'".to_string()),
Some(']') => LiteralError::Syntax("unmatched ']'".to_string()),
_ => LiteralError::Syntax("invalid syntax".to_string()),
}
}
fn expression(&mut self) -> std::result::Result<MetaValue, LiteralError> {
self.skip_space();
let Some(c) = self.peek() else {
return Err(LiteralError::Syntax("invalid syntax".to_string()));
};
match c {
'(' | '[' => self.sequence(c),
'\'' | '"' => {
let mut value = self.string()?;
loop {
self.skip_space();
match self.peek() {
Some('\'' | '"') => value.push_str(&self.string()?),
_ => break,
}
}
Ok(MetaValue::Str(value))
}
'+' | '-' => {
self.position += 1;
self.skip_space();
match self.peek() {
Some(d) if d.is_ascii_digit() || d == '.' => match self.number()? {
MetaValue::Int(value) if c == '-' => Ok(MetaValue::Int(-value)),
MetaValue::Float(value) if c == '-' => Ok(MetaValue::Float(-value)),
value => Ok(value),
},
_ => Err(Self::malformed()),
}
}
c if c.is_ascii_digit() || c == '.' => self.number(),
c if c.is_alphabetic() || c == '_' => {
let start = self.position;
while self.peek().is_some_and(|c| c.is_alphanumeric() || c == '_') {
self.position += 1;
}
let word: String = self.chars[start..self.position].iter().collect();
match word.as_str() {
"True" => Ok(MetaValue::Bool(true)),
"False" => Ok(MetaValue::Bool(false)),
"None" => Ok(MetaValue::None),
_ => Err(Self::malformed()),
}
}
'{' => Err(Self::malformed()),
_ => Err(LiteralError::Syntax("invalid syntax".to_string())),
}
}
fn sequence(&mut self, open: char) -> std::result::Result<MetaValue, LiteralError> {
let close = if open == '(' { ')' } else { ']' };
self.position += 1;
let mut items = Vec::new();
let mut trailing_comma = false;
loop {
self.skip_space();
match self.peek() {
None => return Err(LiteralError::Syntax(format!("'{open}' was never closed"))),
Some(c) if c == close => {
self.position += 1;
break;
}
_ => {}
}
items.push(self.expression()?);
self.skip_space();
match self.peek() {
Some(',') => {
self.position += 1;
trailing_comma = true;
}
Some(c) if c == close => {
trailing_comma = false;
}
None => return Err(LiteralError::Syntax(format!("'{open}' was never closed"))),
Some(_) => return Err(self.trailing()),
}
}
Ok(if open == '[' {
MetaValue::List(items)
} else if items.len() == 1 && !trailing_comma {
items.pop().expect("one item")
} else {
MetaValue::Tuple(items)
})
}
fn string(&mut self) -> std::result::Result<String, LiteralError> {
let quote = self.peek().expect("a quote");
self.position += 1;
let mut value = String::new();
loop {
let Some(c) = self.peek() else {
return Err(LiteralError::Syntax(
"unterminated string literal (detected at line 1)".to_string(),
));
};
self.position += 1;
if c == quote {
return Ok(value);
}
if c != '\\' {
value.push(c);
continue;
}
let Some(escaped) = self.peek() else {
return Err(LiteralError::Syntax(
"unterminated string literal (detected at line 1)".to_string(),
));
};
self.position += 1;
match escaped {
'n' => value.push('\n'),
't' => value.push('\t'),
'r' => value.push('\r'),
'0' => value.push('\0'),
'\\' | '\'' | '"' => value.push(escaped),
other => {
value.push('\\');
value.push(other);
}
}
}
}
fn number(&mut self) -> std::result::Result<MetaValue, LiteralError> {
let start = self.position;
while self
.peek()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
let c = self.peek().expect("checked");
self.position += 1;
if matches!(c, 'e' | 'E') && matches!(self.peek(), Some('+' | '-')) {
let radix_prefix = self.chars[start..].starts_with(&['0', 'x'])
|| self.chars[start..].starts_with(&['0', 'X']);
if !radix_prefix {
self.position += 1;
}
}
}
let word: String = self.chars[start..self.position]
.iter()
.filter(|&&c| c != '_')
.collect();
let invalid = || LiteralError::Syntax("invalid syntax".to_string());
let lower = word.to_ascii_lowercase();
for (prefix, radix) in [("0x", 16), ("0o", 8), ("0b", 2)] {
if let Some(digits) = lower.strip_prefix(prefix) {
return i64::from_str_radix(digits, radix)
.map(MetaValue::Int)
.map_err(|_| invalid());
}
}
if word.chars().all(|c| c.is_ascii_digit()) {
if word.len() > 1 && word.starts_with('0') && word.chars().any(|c| c != '0') {
return Err(invalid());
}
return word.parse().map(MetaValue::Int).map_err(|_| invalid());
}
word.parse::<f64>()
.ok()
.filter(|_| !lower.contains("inf") && !lower.contains("nan"))
.map(MetaValue::Float)
.ok_or_else(invalid)
}
}
fn render_inner(markup: &str, emoji: bool, base_style: &StyleType) -> 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) if emoji => push_plain(&mut plain, &crate::emoji::replace(&chunk)),
Event::Text(chunk) => push_plain(&mut plain, &chunk),
Event::Tag {
name: tag_name,
parameters,
position,
} => {
let at = || markup[..position].chars().count();
if let Some(name) = tag_name.strip_prefix('/') {
let name = name.trim();
let end = plain.len();
let closing = Tag::new(tag_name, parameters.clone()).markup();
let (open_name, open_parameters, start) = if name.is_empty() {
stack.pop().ok_or_else(|| {
RichError::Markup(format!(
"closing tag '[/]' at position {} has nothing to close",
at()
))
})?
} else {
let wanted = Style::normalize(name);
let pos = stack
.iter()
.rposition(|(open, _, _)| *open == wanted)
.ok_or_else(|| {
RichError::Markup(format!(
"closing tag '{closing}' at position {} doesn't match any open tag",
at()
))
})?;
stack.remove(pos)
};
let style = if open_name.starts_with('@') {
StyleType::Style(Style::from_meta(handler_meta(
&open_name,
open_parameters.as_deref(),
)?))
} else {
StyleType::Name(Tag::new(open_name, open_parameters).to_string())
};
raw_spans.push(RawSpan { start, end, style });
} 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,
style: StyleType::Name(Tag::new(open_name, open_parameters).to_string()),
});
}
let mut spans: Vec<Span> = raw_spans
.into_iter()
.map(|raw| Span {
start: raw.start,
end: raw.end,
style: raw.style,
})
.collect();
spans.reverse();
spans.sort_by_key(|span| span.start);
let mut text = Text::styled(plain, base_style.clone());
for span in spans {
text.push_span(span);
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_positions_count_characters_of_the_original() {
let message = |source: &str| match render_emoji(source) {
Err(RichError::Markup(message)) => message,
other => panic!("{other:?}"),
};
assert!(message("é中[/i]").contains("position 2 "));
assert!(message(":smile: [/i]").contains("position 8 "));
assert_eq!(render_emoji("[b]:smile:[/b]").unwrap().plain(), "😄");
assert_eq!(render("[b]:smile:[/b]").unwrap().plain(), ":smile:");
}
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]");
}
}