use crate::core::render::Renderable;
use crate::core::style::{Color, Style};
use crate::core::text::{Line, Span, Text};
use crate::error::Result;
pub fn parse(markup: &str) -> Text {
Parser::new().run(markup)
}
pub fn markup_print(markup: &str) -> Result<()> {
parse(markup).print()
}
pub fn markup_println(markup: &str) -> Result<()> {
let mut text = parse(markup);
text.push_line(Line::default());
text.print()
}
struct Parser {
stack: Vec<Style>,
lines: Vec<Line>,
spans: Vec<Span>,
buffer: String,
in_code: bool,
}
impl Parser {
fn new() -> Self {
Self {
stack: vec![Style::new()],
lines: Vec::new(),
spans: Vec::new(),
buffer: String::new(),
in_code: false,
}
}
fn run(mut self, markup: &str) -> Text {
let mut chars = markup.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => self.push_escaped(chars.next()),
'[' => self.handle_tag(&mut chars),
'`' => self.toggle_code(),
'\n' => self.break_line(),
_ => self.buffer.push(ch),
}
}
self.finish()
}
fn push_escaped(&mut self, next: Option<char>) {
match next {
Some(ch) => self.buffer.push(ch),
None => self.buffer.push('\\'),
}
}
fn handle_tag(&mut self, chars: &mut std::iter::Peekable<std::str::Chars>) {
let mut tag = String::new();
let mut closed = false;
for ch in chars.by_ref() {
if ch == ']' {
closed = true;
break;
}
tag.push(ch);
}
if !closed {
self.buffer.push('[');
self.buffer.push_str(&tag);
return;
}
self.apply_tag(&tag);
}
fn apply_tag(&mut self, raw: &str) {
let tag = raw.trim();
if tag == "/" {
self.flush_span();
if self.stack.len() > 1 {
self.stack.pop();
}
return;
}
let specs = parse_specs(tag);
if specs == Style::new() {
self.buffer.push('[');
self.buffer.push_str(raw);
self.buffer.push(']');
return;
}
self.flush_span();
let current = self.current_style();
self.stack.push(current.patch(specs));
}
fn toggle_code(&mut self) {
self.flush_span();
if self.in_code {
if self.stack.len() > 1 {
self.stack.pop();
}
} else {
self.stack.push(code_style(self.current_style()));
}
self.in_code = !self.in_code;
}
fn current_style(&self) -> Style {
*self.stack.last().unwrap_or(&Style::new())
}
fn flush_span(&mut self) {
if self.buffer.is_empty() {
return;
}
let content = std::mem::take(&mut self.buffer);
self.spans.push(Span::styled(content, self.current_style()));
}
fn break_line(&mut self) {
self.flush_span();
self.lines.push(Line::new(std::mem::take(&mut self.spans)));
}
fn finish(mut self) -> Text {
self.flush_span();
self.lines.push(Line::new(std::mem::take(&mut self.spans)));
Text::new(self.lines)
}
}
fn parse_specs(tag: &str) -> Style {
let mut style = Style::new();
let mut expect_background = false;
for token in tag.split_whitespace() {
let lowered = token.to_ascii_lowercase();
if lowered == "on" {
expect_background = true;
continue;
}
if expect_background {
if let Some(color) = parse_color(token) {
style = style.bg(color);
}
expect_background = false;
continue;
}
style = apply_token(style, &lowered, token);
}
style
}
fn apply_token(style: Style, lowered: &str, token: &str) -> Style {
match lowered {
"bold" | "b" => style.bold(),
"dim" | "d" => style.dim(),
"italic" | "i" => style.italic(),
"underline" | "underlined" | "u" => style.underlined(),
"strike" | "strikethrough" | "s" => style.strikethrough(),
_ => match parse_color(token) {
Some(color) => style.fg(color),
None => style,
},
}
}
fn parse_color(token: &str) -> Option<Color> {
if token.starts_with('#') {
Color::from_hex(token)
} else {
Color::from_name(token)
}
}
fn code_style(base: Style) -> Style {
base.patch(Style::new().fg(Color::Cyan))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::style::Attribute;
#[test]
fn parses_attribute_and_color_tag() {
let text = parse("[bold red]warn[/]");
let span = &text.lines[0].spans[0];
assert_eq!(span.content, "warn");
assert_eq!(span.style.fg, Some(Color::Red));
assert!(span.style.attrs.contains(Attribute::BOLD));
}
#[test]
fn parses_background_with_on_keyword() {
let text = parse("[white on blue]x[/]");
let style = text.lines[0].spans[0].style;
assert_eq!(style.bg, Some(Color::Blue));
}
#[test]
fn parses_hex_color() {
let text = parse("[#ff8800]x[/]");
assert_eq!(
text.lines[0].spans[0].style.fg,
Some(Color::Rgb(255, 136, 0))
);
}
#[test]
fn unterminated_bracket_is_literal() {
let text = parse("a [b c");
assert_eq!(text.lines[0].plain(), "a [b c");
}
#[test]
fn closed_unknown_tag_is_literal() {
assert_eq!(parse("array[0]").lines[0].plain(), "array[0]");
assert_eq!(parse("[hello world]").lines[0].plain(), "[hello world]");
}
#[test]
fn recognized_tag_still_applies_alongside_literal_bracket() {
let text = parse("[red]x[/] array[0]");
assert_eq!(text.lines[0].plain(), "x array[0]");
assert_eq!(text.lines[0].spans[0].style.fg, Some(Color::Red));
}
#[test]
fn escaped_bracket_is_literal() {
let text = parse("\\[bold\\]");
assert_eq!(text.lines[0].plain(), "[bold]");
}
#[test]
fn newlines_split_into_lines() {
let text = parse("a\nb");
assert_eq!(text.lines.len(), 2);
}
#[test]
fn code_span_gets_code_style() {
let text = parse("`x`");
assert_eq!(text.lines[0].spans[0].style.fg, Some(Color::Cyan));
}
#[test]
fn attribute_tokens_are_case_insensitive() {
let text = parse("[BOLD]x[/]");
assert!(text.lines[0].spans[0].style.attrs.contains(Attribute::BOLD));
}
#[test]
fn background_keyword_is_case_insensitive() {
let text = parse("[white ON blue]x[/]");
assert_eq!(text.lines[0].spans[0].style.bg, Some(Color::Blue));
}
#[test]
fn tag_inside_code_span_still_closes() {
let text = parse("`[bold]a`b");
let trailing = text.lines[0].spans.last().unwrap();
assert_eq!(trailing.content, "b");
assert!(!trailing.style.attrs.contains(Attribute::BOLD));
}
}