rich.rs
raw
//! 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<Result<Vec<RichRun>, 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<f64>,
link: Option<&'a str>,
}
fn flatten<'a>(
item: &'a Sexpr,
style: Style<'a>,
runs: &mut Vec<RichRun>,
) -> 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;