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;