theme.rs raw

//! Theme file compiler: `(theme …)` forms with design tokens and style
//! rules, sharing the `.gdc` reader and diagnostics.
//!
//! ```lisp
//! (theme Light
//!   (tokens
//!     (color-accent "#4682b4")
//!     (radius-m 8))
//!   (rule (container .button) :background color-accent :corner-radius radius-m)
//!   (rule (container .button :hover) :background "#6495ed"))
//! ```
//!
//! Selectors are `widget`, `(widget .class …)`, or `(widget .class… :state)`
//! — type, own classes, and own interaction state only. There are
//! deliberately no descendant combinators: a widget's style never depends on
//! its ancestors, so style invalidation cannot cascade.
//!
//! Rule order is priority: for a property matched by several rules, the last
//! matching rule in the file wins.
//!
//! Validation enforces *base coverage*: a rule with an interaction state may
//! only style properties that some stateless rule (with the same or broader
//! selector) also styles. This guarantees that leaving the state always has
//! a value to return to, so the engine never needs per-widget snapshots of
//! pre-theme values.

use crate::diagnostics::Diagnostic;
use crate::ir::{Literal, parse_color};
use crate::registry::{self, Registry};
use crate::sexpr::{self, Sexpr, Span};

/// A compiled theme: plain data for the runtime style engine to translate.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeIr {
    pub name: String,
    pub rules: Vec<RuleIr>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct RuleIr {
    /// The name of the widget type this rule selects — a builtin, or one an
    /// application declared in a `.gdw`. The runtime engine matches rules on
    /// this name.
    pub widget: String,
    pub classes: Vec<String>,
    pub state: Option<StateSel>,
    pub props: Vec<ThemeProp>,
}

/// One property of a rule: its name and resolved literal value (token
/// references already inlined), plus the span of the value.
///
/// The span is here because compiling a theme is only half of resolving it: a
/// value like `(asset "icons/check.png")` names a file the *consumer* has to
/// find, and a failure there deserves the same pointed diagnostic as any
/// other theme error.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeProp {
    pub name: String,
    pub value: Literal,
    pub span: Span,
}

/// Interaction states a rule can target.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum StateSel {
    Hover,
    Active,
    Focus,
    Disabled,
}

impl StateSel {
    fn parse(name: &str) -> Option<Self> {
        Some(match name {
            "hover" => Self::Hover,
            "active" => Self::Active,
            "focus" => Self::Focus,
            "disabled" => Self::Disabled,
            _ => return None,
        })
    }
}

/// Compile theme source to [`ThemeIr`] against the builtin widget vocabulary
/// alone.
pub fn compile_theme(source: &str) -> Result<ThemeIr, Vec<Diagnostic>> {
    compile_theme_with(source, &Registry::default())
}

/// Compile theme source against a vocabulary that includes the widgets an
/// application declared in its `.gdw` manifests, so a rule may name one and
/// set the tokens its manifest declares.
pub fn compile_theme_with(source: &str, registry: &Registry) -> Result<ThemeIr, Vec<Diagnostic>> {
    let doc = sexpr::read(source).map_err(|e| vec![Diagnostic::new(e.message, e.span)])?;
    let mut forms = doc.values.iter();
    let Some(form) = forms.next() else {
        return Err(vec![Diagnostic::new(
            "file contains no `(theme …)` form",
            Span::new(0, 0),
        )]);
    };
    if let Some(extra) = forms.next() {
        return Err(vec![Diagnostic::new(
            "a theme file holds exactly one `(theme …)` form",
            extra.span(),
        )]);
    }
    theme(form, source, registry)
}

fn theme(form: &Sexpr, source: &str, registry: &Registry) -> Result<ThemeIr, Vec<Diagnostic>> {
    let mut errors = Vec::new();
    let Some(items) = form.as_list() else {
        return Err(vec![Diagnostic::new(
            "expected a `(theme …)` form",
            form.span(),
        )]);
    };
    if items.first().and_then(Sexpr::as_symbol) != Some("theme") {
        return Err(vec![Diagnostic::new(
            "expected a `(theme …)` form",
            form.span(),
        )]);
    }
    let name = match items.get(1) {
        Some(Sexpr::Symbol(name, _)) => name.clone(),
        other => {
            return Err(vec![Diagnostic::new(
                "theme needs a name symbol",
                other.map_or(form.span(), Sexpr::span),
            )]);
        }
    };

    let mut tokens: Vec<(String, Literal, Span)> = Vec::new();
    let mut rules = Vec::new();

    for item in &items[2..] {
        let Some(list) = item.as_list() else {
            errors.push(Diagnostic::new(
                "expected a `(tokens …)` or `(rule …)` form",
                item.span(),
            ));
            continue;
        };
        match list.first().and_then(Sexpr::as_symbol) {
            Some("tokens") => parse_tokens(&list[1..], &mut tokens, &mut errors, source),
            Some("rule") => {
                if let Some(rule) = parse_rule(
                    &list[1..],
                    item.span(),
                    &tokens,
                    registry,
                    &mut errors,
                    source,
                ) {
                    rules.push(rule);
                }
            }
            _ => errors.push(Diagnostic::new(
                "expected `(tokens …)` or `(rule …)`",
                item.span(),
            )),
        }
    }

    check_base_coverage(&rules, &mut errors, form.span());

    if errors.is_empty() {
        Ok(ThemeIr { name, rules })
    } else {
        Err(errors)
    }
}

/// `(tokens (name value) …)`
fn parse_tokens(
    entries: &[Sexpr],
    tokens: &mut Vec<(String, Literal, Span)>,
    errors: &mut Vec<Diagnostic>,
    source: &str,
) {
    for entry in entries {
        let Some(pair) = entry.as_list() else {
            errors.push(Diagnostic::new(
                "expected a `(name value)` token pair",
                entry.span(),
            ));
            continue;
        };
        let (Some(Sexpr::Symbol(name, name_span)), Some(value), true) =
            (pair.first(), pair.get(1), pair.len() == 2)
        else {
            errors.push(Diagnostic::new(
                "expected a `(name value)` token pair",
                entry.span(),
            ));
            continue;
        };
        if tokens.iter().any(|(n, _, _)| n == name) {
            errors.push(Diagnostic::new(
                format!("token `{name}` is defined more than once"),
                *name_span,
            ));
            continue;
        }
        match token_value(value, tokens, source) {
            Ok(literal) => tokens.push((name.clone(), literal, *name_span)),
            Err(diag) => errors.push(diag),
        }
    }
}

/// A token value: a literal, a color string, or a reference to an earlier
/// token.
fn token_value(
    value: &Sexpr,
    tokens: &[(String, Literal, Span)],
    _source: &str,
) -> Result<Literal, Diagnostic> {
    match value {
        Sexpr::Int(v, _) => Ok(Literal::Int(*v)),
        Sexpr::Float(v, _) => Ok(Literal::Float(*v)),
        Sexpr::Str(text, span) => {
            if text.starts_with('#') {
                parse_color(text).ok_or_else(|| {
                    Diagnostic::new(
                        format!(
                            "`{text}` is not a color; expected `#rgb`, `#rrggbb`, or `#rrggbbaa`"
                        ),
                        *span,
                    )
                })
            } else {
                Ok(Literal::Str(text.clone()))
            }
        }
        Sexpr::Symbol(name, span) => tokens
            .iter()
            .find(|(n, _, _)| n == name)
            .map(|(_, v, _)| v.clone())
            .ok_or_else(|| {
                Diagnostic::new(
                    format!("`{name}` is not a defined token (tokens must be defined before use)"),
                    *span,
                )
            }),
        Sexpr::List(items, span) => {
            if let Some(result) = crate::path::parse_path_form(items, *span) {
                return result.map(Literal::Path);
            }
            if let Some(result) = crate::asset::parse_asset_form(items, *span) {
                return result.map(Literal::Asset);
            }
            Err(Diagnostic::new(
                "expected a color, number, string, token name, `(path …)`, or `(asset …)`",
                *span,
            ))
        }
        other => Err(Diagnostic::new(
            format!("invalid token value ({})", other.kind_name()),
            other.span(),
        )),
    }
}

/// Check a resolved theme value matches the type its property declares.
fn value_matches(value: &Literal, expected: registry::ThemeValueType) -> bool {
    use registry::ThemeValueType as T;
    match expected {
        T::Brush => matches!(value, Literal::Color(..)),
        T::Number => matches!(value, Literal::Int(_) | Literal::Float(_)),
        T::Str => matches!(value, Literal::Str(_)),
        // Paths and assets are interchangeable wherever a graphic is wanted.
        T::Graphic => matches!(value, Literal::Path(_) | Literal::Asset(_)),
    }
}

fn type_name(expected: registry::ThemeValueType) -> &'static str {
    use registry::ThemeValueType as T;
    match expected {
        T::Brush => "a color",
        T::Number => "a number",
        T::Str => "a string",
        T::Graphic => "a graphic: `(path …)`, `(svg-path …)`, or `(asset …)`",
    }
}

/// `(rule selector :prop value …)`
fn parse_rule(
    items: &[Sexpr],
    span: Span,
    tokens: &[(String, Literal, Span)],
    registry: &Registry,
    errors: &mut Vec<Diagnostic>,
    source: &str,
) -> Option<RuleIr> {
    let Some(selector) = items.first() else {
        errors.push(Diagnostic::new("rule needs a selector", span));
        return None;
    };
    let (widget, classes, state) = parse_selector(selector, registry, errors)?;
    let descriptor = registry
        .resolve(&widget)
        .expect("parse_selector resolved the widget");

    let mut props = Vec::new();
    let mut rest = items[1..].iter();
    while let Some(item) = rest.next() {
        let Sexpr::Keyword(prop_name, prop_span) = item else {
            errors.push(Diagnostic::new(
                format!(
                    "expected `:property value` pairs, found {}",
                    item.kind_name()
                ),
                item.span(),
            ));
            return None;
        };
        let Some(value) = rest.next() else {
            errors.push(Diagnostic::new(
                format!("`:{prop_name}` needs a value"),
                *prop_span,
            ));
            return None;
        };
        // Themes carry visual appearance tokens (brushes, metrics, marks) —
        // not layout or events. The theme-property table is the source of
        // truth for what is themable and what type each token expects.
        let Some(expected) = descriptor.theme_value_type(prop_name) else {
            match descriptor.lookup(prop_name) {
                Some(_) => errors.push(Diagnostic::new(
                    format!(
                        "`:{prop_name}` is not themable; themes set visual properties, \
                         not layout or events"
                    ),
                    *prop_span,
                )),
                None => errors.push(Diagnostic::new(
                    format!("`{widget}` has no themable property `:{prop_name}`"),
                    *prop_span,
                )),
            }
            continue;
        };
        match token_value(value, tokens, source) {
            Ok(literal) if value_matches(&literal, expected) => props.push(ThemeProp {
                name: prop_name.clone(),
                value: literal,
                span: value.span(),
            }),
            Ok(_) => errors.push(Diagnostic::new(
                format!("`:{prop_name}` expects {}", type_name(expected)),
                value.span(),
            )),
            Err(diag) => errors.push(diag),
        }
    }

    Some(RuleIr {
        widget,
        classes,
        state,
        props,
    })
}

/// `widget` or `(widget .class… :state?)`
fn parse_selector(
    selector: &Sexpr,
    registry: &Registry,
    errors: &mut Vec<Diagnostic>,
) -> Option<(String, Vec<String>, Option<StateSel>)> {
    let parse_widget = |name: &str, span: Span, errors: &mut Vec<Diagnostic>| {
        registry
            .resolve(name)
            .map(|d| d.name.to_string())
            .or_else(|| {
                errors.push(Diagnostic::new(
                    format!("unknown widget `{name}` in selector"),
                    span,
                ));
                None
            })
    };
    match selector {
        Sexpr::Symbol(name, span) => Some((parse_widget(name, *span, errors)?, Vec::new(), None)),
        Sexpr::List(items, span) => {
            let Some(Sexpr::Symbol(name, name_span)) = items.first() else {
                errors.push(Diagnostic::new(
                    "selector must start with a widget name",
                    *span,
                ));
                return None;
            };
            let widget = parse_widget(name, *name_span, errors)?;
            let mut classes = Vec::new();
            let mut state = None;
            for item in &items[1..] {
                match item {
                    Sexpr::Symbol(text, span) if text.starts_with('.') => {
                        classes.push(text[1..].to_owned());
                        if text.len() == 1 {
                            errors.push(Diagnostic::new("empty class selector", *span));
                            return None;
                        }
                    }
                    Sexpr::Keyword(name, span) => match StateSel::parse(name) {
                        Some(s) if state.is_none() => state = Some(s),
                        Some(_) => {
                            errors.push(Diagnostic::new("selector already has a state", *span));
                            return None;
                        }
                        None => {
                            errors.push(Diagnostic::new(
                                format!(
                                    "unknown state `:{name}`; expected :hover, :active, :focus, or :disabled"
                                ),
                                *span,
                            ));
                            return None;
                        }
                    },
                    other => {
                        errors.push(Diagnostic::new(
                            format!(
                                "selector items are `.class` or `:state`, found {}",
                                other.kind_name()
                            ),
                            other.span(),
                        ));
                        return None;
                    }
                }
            }
            Some((widget, classes, state))
        }
        other => {
            errors.push(Diagnostic::new(
                format!("expected a selector, found {}", other.kind_name()),
                other.span(),
            ));
            None
        }
    }
}

/// Every property in a stateful rule must be covered by some stateless rule
/// whose selector is the same or broader (class subset), so leaving the
/// state always restores a themed value.
fn check_base_coverage(rules: &[RuleIr], errors: &mut Vec<Diagnostic>, theme_span: Span) {
    for rule in rules.iter().filter(|r| r.state.is_some()) {
        for prop in &rule.props {
            let covered = rules.iter().any(|base| {
                base.state.is_none()
                    && base.widget == rule.widget
                    && base.classes.iter().all(|c| rule.classes.contains(c))
                    && base.props.iter().any(|p| p.name == prop.name)
            });
            if !covered {
                errors.push(Diagnostic::new(
                    format!(
                        "state rule for `{}` sets `:{}` but no stateless rule \
                         covers it; add a base rule so leaving the state has a \
                         value to restore",
                        rule.widget, prop.name
                    ),
                    theme_span,
                ));
            }
        }
    }
}

#[cfg(test)]
mod tests;