//! Rich text: `(rich "Hello " (bold "World") (color "#ff00ff" "!"))`. //! //! Nesting *is* the structure, which is what lets this be a data form rather //! than a markup language embedded in a string. There is no parser for tags, //! no escaping, no way to leave one unclosed: the reader that reads every //! other form reads this one, and a mismatched paren is a paren error like //! any other. //! //! Styles compose by nesting — `(bold (color "#f00" "x"))` is bold and red — //! and the tree flattens here, at compile time, into a plain string plus the //! ranges that differ from it. Nothing at runtime walks a tree. use crate::diagnostics::Diagnostic; use crate::ir::{RichRun, parse_color}; use crate::sexpr::{Sexpr, Span}; /// The head symbol of a rich-text form. pub const RICH_FORM: &str = "rich"; /// Parse a `(rich …)` form into flattened runs. Returns `None` (not an error) /// when the head is something else, so callers can fall through. pub fn parse_rich_form(items: &[Sexpr], span: Span) -> Option, Diagnostic>> { if items.first().and_then(Sexpr::as_symbol) != Some(RICH_FORM) { return None; } let mut runs = Vec::new(); for item in &items[1..] { if let Err(diagnostic) = flatten(item, Style::default(), &mut runs) { return Some(Err(diagnostic)); } } if runs.is_empty() { return Some(Err(Diagnostic::new("`rich` needs some text", span))); } Some(Ok(runs)) } /// The styling in force at a point in the tree; children inherit and add. /// /// A link is carried here beside the styles rather than beside the text, /// because that is what makes `(link "home" (bold "Home"))` mean what it reads /// like: the link form nests exactly as `bold` does, and neither has to know /// the other exists. #[derive(Clone, Copy, Default)] struct Style<'a> { bold: bool, italic: bool, underline: bool, strikethrough: bool, color: Option<(u8, u8, u8, u8)>, size: Option, link: Option<&'a str>, } fn flatten<'a>( item: &'a Sexpr, style: Style<'a>, runs: &mut Vec, ) -> Result<(), Diagnostic> { match item { Sexpr::Str(text, _) => { runs.push(RichRun { text: text.clone(), bold: style.bold, italic: style.italic, underline: style.underline, strikethrough: style.strikethrough, color: style.color, size: style.size, link: style.link.map(str::to_owned), }); Ok(()) } Sexpr::List(items, span) => { let Some(head) = items.first().and_then(Sexpr::as_symbol) else { return Err(Diagnostic::new( "a rich-text part is a string or a style form like `(bold …)`", *span, )); }; // The style forms that take a value take it first; the rest of a // form is always its content, however deep. let (style, content) = match head { "bold" => ( Style { bold: true, ..style }, &items[1..], ), "italic" => ( Style { italic: true, ..style }, &items[1..], ), "underline" => ( Style { underline: true, ..style }, &items[1..], ), "strike" => ( Style { strikethrough: true, ..style }, &items[1..], ), "color" => { let Some(Sexpr::Str(text, color_span)) = items.get(1) else { return Err(Diagnostic::new( "`color` takes a color first: `(color \"#ff00ff\" \"…\")`", *span, )); }; let color = parse_color(text).ok_or_else(|| { Diagnostic::new( format!("`{text}` is not a color; expected `#rgb`, `#rrggbb`, or `#rrggbbaa`"), *color_span, ) })?; let crate::ir::Literal::Color(r, g, b, a) = color else { unreachable!("parse_color yields a color"); }; ( Style { color: Some((r, g, b, a)), ..style }, &items[2..], ) } "size" => { let size = match items.get(1) { Some(Sexpr::Int(v, _)) => *v as f64, Some(Sexpr::Float(v, _)) => *v, _ => { return Err(Diagnostic::new( "`size` takes a number first: `(size 20 \"…\")`", *span, )); } }; ( Style { size: Some(size), ..style }, &items[2..], ) } // A link is a style form like the rest — it takes its target // first and its content after — which is the whole reason // `(link "home" (bold "Home"))` needs no rule of its own. "link" => { let target = match items.get(1) { Some(Sexpr::Str(target, _)) => target.as_str(), // Point at what was written where the target belongs; // fall back to the form when nothing was. other => { return Err(Diagnostic::new( "`link` takes a target first: `(link \"home\" \"…\")`", other.map_or(*span, Sexpr::span), )); } }; ( Style { link: Some(target), ..style }, &items[2..], ) } other => { return Err(Diagnostic::new( format!( "`{other}` is not a rich-text style; expected bold, italic, \ underline, strike, color, size, or link" ), *span, )); } }; if content.is_empty() { return Err(Diagnostic::new("this style has no text in it", *span)); } for child in content { flatten(child, style, runs)?; } Ok(()) } other => Err(Diagnostic::new( "a rich-text part is a string or a style form like `(bold …)`", other.span(), )), } } #[cfg(test)] mod tests;