//! 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, } #[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, pub state: Option, pub props: Vec, } /// 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 { 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> { 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> { 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> { 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, 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 { 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, source: &str, ) -> Option { 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, ) -> Option<(String, Vec, Option)> { let parse_widget = |name: &str, span: Span, errors: &mut Vec| { 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, 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;