Skip to main content

eui_core/
macros.rs

1//! The macro expander: applies `catalog/macros.json` (plus extension macros)
2//! to a normalized tree until every node is native. Mirrors `src/macros.ts`;
3//! `fixtures/catalog-macros.json` locks the output.
4//!
5//! Round 1 (docs/round-1-contract.md §1–2): a `$if`/`$any` that cannot be
6//! decided at expansion time (a bound input) emits the part WITH `visible` =
7//! the bound condition; `"$slot:name"` children splice an author slot in
8//! place and a template `slots` entry may be a template node; a RESPONSIVE
9//! prop (`{base, sm?, md?…}`) is expanded per breakpoint into `@media
10//! (min-width: $breakpoint.<bp>)` blocks on every part whose style it
11//! changes; a `$set` on a part whose macro prop is BOUND adds the `set`
12//! write-back to that part's action (two-way binding without a host round
13//! trip); a part's `$a11y` becomes the node's `accessibility` (role, states,
14//! name: §6).
15
16use std::collections::HashSet;
17use std::sync::LazyLock;
18
19use indexmap::IndexMap;
20use serde_json::{Map, Value};
21
22use crate::catalog::{CatalogView, TOKEN_GROUPS};
23use crate::expr::{eval_condition_value, eval_value, is_binding, Decided, ExprContext};
24use crate::json;
25use crate::types::{MacroDef, MacroTemplate, Props, Recipe, ReduceIssue, TemplateChild, UiNode};
26
27const MAX_DEPTH: usize = 16;
28
29/// The breakpoint names in ascending order (`tokens.json` `breakpoint`).
30pub static BREAKPOINTS: LazyLock<Vec<String>> = LazyLock::new(|| TOKEN_GROUPS.get("breakpoint").cloned().unwrap_or_default());
31
32struct Variant {
33    breakpoint: String,
34    props: Props,
35}
36
37struct Expansion<'a> {
38    view: &'a CatalogView,
39    macro_: &'a str,
40    def: &'a MacroDef,
41    source: &'a UiNode,
42    /// The author's props with every responsive value at its `base`.
43    base_props: Props,
44    /// One per breakpoint a responsive prop names, ascending.
45    variants: Vec<Variant>,
46    claimed: HashSet<String>,
47    /// Expansion problems (an author function that replaces a `$set` write).
48    issues: Vec<ReduceIssue>,
49}
50
51/// `{base, sm?, md?, lg?, xl?}`: an object with `base` and only breakpoint
52/// keys besides.
53pub fn is_responsive_value(value: &Value) -> bool {
54    let Some(obj) = value.as_object() else { return false };
55    obj.contains_key("base") && obj.keys().all(|k| k == "base" || BREAKPOINTS.iter().any(|b| b == k))
56}
57
58/// The value a responsive prop takes at a breakpoint: the nearest defined
59/// key at or below it, `base` under the first one. `None` = the base.
60pub fn responsive_at<'v>(value: &'v Value, breakpoint: Option<&str>) -> Option<&'v Value> {
61    let obj = value.as_object()?;
62    let mut out = obj.get("base");
63    let Some(breakpoint) = breakpoint else { return out };
64    for bp in BREAKPOINTS.iter() {
65        if let Some(v) = obj.get(bp) {
66            out = Some(v);
67        }
68        if bp == breakpoint {
69            break;
70        }
71    }
72    out
73}
74
75/// The author's props with every responsive object replaced by its value at
76/// `breakpoint` (`None` = base).
77pub fn props_at(props: &Props, breakpoint: Option<&str>) -> Props {
78    let mut out = Props::new();
79    for (k, v) in props {
80        if is_responsive_value(v) {
81            if let Some(at) = responsive_at(v, breakpoint) {
82                out.insert(k.clone(), at.clone());
83            }
84        } else {
85            out.insert(k.clone(), v.clone());
86        }
87    }
88    out
89}
90
91fn responsive_variants(props: &Props) -> Vec<Variant> {
92    let mut used: HashSet<&str> = HashSet::new();
93    for v in props.values() {
94        if is_responsive_value(v) {
95            for k in v.as_object().into_iter().flat_map(|o| o.keys()) {
96                if k != "base" {
97                    used.insert(k.as_str());
98                }
99            }
100        }
101    }
102    BREAKPOINTS
103        .iter()
104        .filter(|bp| used.contains(bp.as_str()))
105        .map(|bp| Variant { breakpoint: bp.clone(), props: props_at(props, Some(bp)) })
106        .collect()
107}
108
109/// JS `{ ...value }` for the value an object spread meets: objects copy,
110/// strings spread their characters by index, everything else is `{}`.
111fn spread(value: Option<&Value>) -> Map<String, Value> {
112    match value {
113        Some(Value::Object(map)) => map.clone(),
114        Some(Value::String(s)) => s.chars().enumerate().map(|(i, c)| (i.to_string(), Value::String(c.to_string()))).collect(),
115        _ => Map::new(),
116    }
117}
118
119/// Merge `over` into `base`; condition objects (`@media …`, `:pressed`) merge
120/// key by key.
121fn merge_style(base: Option<&Props>, over: Option<&Props>) -> Option<Props> {
122    if base.is_none() && over.is_none() {
123        return None;
124    }
125    let mut out = base.cloned().unwrap_or_default();
126    for (k, v) in over.into_iter().flatten() {
127        if let Value::Object(nested) = v {
128            let mut merged = spread(out.get(k));
129            for (nk, nv) in nested {
130                merged.insert(nk.clone(), nv.clone());
131            }
132            out.insert(k.clone(), Value::Object(merged));
133        } else {
134            out.insert(k.clone(), v.clone());
135        }
136    }
137    Some(out)
138}
139
140/// A deep copy of `action` with `context` merged into `event.context`.
141fn routed_action(action: &Value, context: Option<&Value>) -> Value {
142    let mut out = action.clone();
143    if let (Some(context), Some(Value::Object(event))) = (context.filter(|c| js_truthy(c)), out.get_mut("event")) {
144        let mut merged = spread(event.get("context").filter(|c| !c.is_null()));
145        merged.extend(spread(Some(context)));
146        event.insert("context".into(), Value::Object(merged));
147    }
148    out
149}
150
151/// The macro's recipe discriminators: the author's value, else the prop's
152/// catalog default, then the evaluated `$recipe` keys.
153fn recipe_props(exp: &Expansion, ctx: &ExprContext, extra: Option<&Props>) -> Props {
154    let mut props = Props::new();
155    let schema = exp.view.components.get(exp.macro_).map(|d| &d.props);
156    for name in &exp.def.recipe_props {
157        let value = exp
158            .base_props
159            .get(name)
160            .filter(|v| !v.is_null())
161            .cloned()
162            .or_else(|| schema.and_then(|s| s.get(name)).and_then(|p| p.default.clone()));
163        if let Some(value) = value {
164            props.insert(name.clone(), value);
165        }
166    }
167    if let Some(extra) = extra {
168        if let Some(Value::Object(evaluated)) = eval_value(&Value::Object(extra.clone()), ctx) {
169            for (k, v) in evaluated {
170                props.insert(k, v);
171            }
172        }
173    }
174    props
175}
176
177/// `and{values}` / `or{values}` over several conditions; one alone is itself.
178fn combine(mut conditions: Vec<Value>, op: &str) -> Value {
179    if conditions.len() == 1 {
180        return conditions.pop().expect("one");
181    }
182    crate::expr::call(op, vec![("values", Some(Value::Array(conditions)))])
183}
184
185/// The part's `$if`/`$any`: `Bool(false)` = leave it out, `Bool(true)` =
186/// emit it, a dynamic value = emit it with that as `visible`.
187fn decide_conditions(tpl: &MacroTemplate, ctx: &ExprContext) -> Decided {
188    let mut bound: Vec<Value> = Vec::new();
189    if let Some(cond) = &tpl.if_ {
190        let conds: Vec<&Value> = match cond {
191            Value::Array(list) => list.iter().collect(),
192            single => vec![single],
193        };
194        for c in conds {
195            match eval_condition_value(Some(c), ctx) {
196                Decided::Bool(false) => return Decided::Bool(false),
197                Decided::Bool(true) => {}
198                Decided::Dynamic(v) => bound.push(v),
199            }
200        }
201    }
202    if let Some(any) = &tpl.any {
203        let mut decided = false;
204        let mut dynamic: Vec<Value> = Vec::new();
205        for c in any {
206            match eval_condition_value(Some(c), ctx) {
207                Decided::Bool(true) => {
208                    decided = true;
209                    break;
210                }
211                Decided::Bool(false) => {}
212                Decided::Dynamic(v) => dynamic.push(v),
213            }
214        }
215        if !decided {
216            if dynamic.is_empty() {
217                return Decided::Bool(false);
218            }
219            bound.push(combine(dynamic, "or"));
220        }
221    }
222    if bound.is_empty() {
223        Decided::Bool(true)
224    } else {
225        Decided::Dynamic(combine(bound, "and"))
226    }
227}
228
229fn expand_template(tpl: &MacroTemplate, exp: &mut Expansion, ctx: &ExprContext, parent_id: &str) -> Vec<UiNode> {
230    let visible = match decide_conditions(tpl, ctx) {
231        Decided::Bool(false) => return Vec::new(),
232        Decided::Bool(true) => None,
233        Decided::Dynamic(v) => Some(v),
234    };
235    if let Some(each) = &tpl.each {
236        let Some(Value::Array(items)) = eval_value(&Value::String(format!("{{{each}}}")), ctx) else {
237            return Vec::new();
238        };
239        let as_ = tpl.as_.as_deref().unwrap_or("item");
240        return items
241            .into_iter()
242            .enumerate()
243            .map(|(index, item)| {
244                let mut vars = ctx.vars.clone();
245                vars.insert(as_.to_string(), item);
246                vars.insert("index".to_string(), json::number(index as f64));
247                let item_ctx = ExprContext { id: ctx.id, props: ctx.props, vars };
248                build_node(tpl, exp, &item_ctx, format!("{parent_id}.{}.{index}", tpl.part), visible.clone())
249            })
250            .collect();
251    }
252    let id = if tpl.part == "root" { exp.source.id.clone() } else { format!("{parent_id}.{}", tpl.part) };
253    vec![build_node(tpl, exp, ctx, id, visible)]
254}
255
256/// The part's style: evaluated with the base props, then once per
257/// breakpoint variant; every key a variant changes lands in its media block.
258fn part_style(tpl: &MacroTemplate, exp: &Expansion, ctx: &ExprContext) -> Option<Props> {
259    let template = tpl.style.as_ref()?;
260    let Some(Value::Object(mut style)) = eval_value(&Value::Object(template.clone()), ctx) else { return None };
261    let mut previous = style.clone();
262    for variant in &exp.variants {
263        let variant_ctx = ExprContext { id: ctx.id, props: &variant.props, vars: ctx.vars.clone() };
264        let Some(Value::Object(at)) = eval_value(&Value::Object(template.clone()), &variant_ctx) else { continue };
265        let mut diff = Map::new();
266        for (k, v) in &at {
267            if k.starts_with('@') || k.starts_with(':') {
268                continue;
269            }
270            if !previous.get(k).is_some_and(|p| json::equal(p, v)) {
271                diff.insert(k.clone(), v.clone());
272            }
273        }
274        if !diff.is_empty() {
275            let key = format!("@media (min-width: $breakpoint.{})", variant.breakpoint);
276            let mut block = spread(style.get(&key));
277            block.extend(diff);
278            style.insert(key, Value::Object(block));
279        }
280        previous = at;
281    }
282    (!style.is_empty()).then_some(style)
283}
284
285fn build_node(tpl: &MacroTemplate, exp: &mut Expansion, ctx: &ExprContext, id: String, visible: Option<Value>) -> UiNode {
286    let props = match eval_value(&Value::Object(tpl.props.clone().unwrap_or_default()), ctx) {
287        Some(Value::Object(p)) => p,
288        _ => Props::new(),
289    };
290    let mut node = UiNode::new(id.clone(), tpl.component.clone());
291    node.props = props;
292    node.style = part_style(tpl, exp, ctx);
293    node.visible = visible;
294    node.recipe = Some(Recipe {
295        macro_: exp.macro_.to_string(),
296        part: tpl.part.clone(),
297        props: recipe_props(exp, ctx, tpl.recipe.as_ref()),
298        authored: Vec::new(),
299    });
300    if let Some(routes) = &tpl.on {
301        let mut on = IndexMap::new();
302        for (part_event, macro_event) in routes {
303            let Some(action) = exp.source.on.as_ref().and_then(|o| o.get(macro_event)).filter(|a| js_truthy(a)) else {
304                continue;
305            };
306            exp.claimed.insert(macro_event.clone());
307            let context = tpl.context.as_ref().and_then(|c| eval_value(&Value::Object(c.clone()), ctx));
308            on.insert(part_event.clone(), routed_action(action, context.as_ref()));
309        }
310        if !on.is_empty() {
311            node.on = Some(on);
312        }
313    }
314    if let Some(sets) = &tpl.set {
315        for (part_event, spec) in sets {
316            let Some(bound) = exp.base_props.get(&spec.prop).filter(|b| is_binding(b)) else { continue };
317            let has_function = node.on.as_ref().and_then(|on| on.get(part_event)).and_then(|a| a.get("functionCall")).is_some_and(js_truthy);
318            if has_function {
319                // An Action carries ONE `functionCall`: the author's routed
320                // function call wins and the bound prop is not written back (reported,
321                // never lost silently). Route an `event` to keep the write.
322                let macro_event = tpl.on.as_ref().and_then(|o| o.get(part_event)).unwrap_or(part_event);
323                let message = format!(
324                    "on.{macro_event}: a function action replaces the two-way set of props.{}; write it in that function's handler or use an event",
325                    spec.prop
326                );
327                if !exp.issues.iter().any(|i| i.id == exp.source.id && i.message == message) {
328                    exp.issues.push(ReduceIssue { id: exp.source.id.clone(), message });
329                }
330                continue;
331            }
332            let path = bound["path"].clone();
333            let value = eval_value(&spec.value, ctx);
334            let mut args = Map::new();
335            args.insert("path".into(), path);
336            if let Some(value) = value {
337                args.insert("value".into(), value);
338            }
339            let mut function = Map::new();
340            function.insert("call".into(), Value::String("set".into()));
341            function.insert("args".into(), Value::Object(args));
342            let on = node.on.get_or_insert_with(IndexMap::new);
343            let mut action = match on.get(part_event) {
344                Some(Value::Object(existing)) => existing.clone(),
345                _ => Map::new(),
346            };
347            action.insert("functionCall".into(), Value::Object(function));
348            on.insert(part_event.clone(), Value::Object(action));
349        }
350    }
351    if let Some(a11y) = &tpl.a11y {
352        if let Some(Value::Object(evaluated)) = eval_value(&Value::Object(a11y.clone()), ctx) {
353            if !evaluated.is_empty() {
354                node.accessibility = Some(Value::Object(evaluated));
355            }
356        }
357    }
358    if let Some(slots) = &tpl.slots {
359        for (slot, reference) in slots {
360            match reference {
361                TemplateChild::Splice(reference) => {
362                    let Some(name) = reference.strip_prefix("$slot:").filter(|n| !n.is_empty()) else { continue };
363                    if let Some(value) = exp.source.slots.as_ref().and_then(|s| s.get(name)) {
364                        node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), value.clone());
365                    }
366                }
367                TemplateChild::Node(part) => {
368                    if let Some(built) = expand_template(part, exp, ctx, &id).into_iter().next() {
369                        node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), built);
370                    }
371                }
372            }
373        }
374    }
375    for child in tpl.children.iter().flatten() {
376        match child {
377            TemplateChild::Splice(s) if s == "$children" => {
378                node.children.extend(exp.source.children.iter().cloned());
379                if let Some(template) = &exp.source.template {
380                    node.template = Some(template.clone());
381                }
382            }
383            TemplateChild::Splice(s) => {
384                if let Some(name) = s.strip_prefix("$slot:") {
385                    if let Some(value) = exp.source.slots.as_ref().and_then(|slots| slots.get(name)) {
386                        node.children.push(value.clone());
387                    }
388                }
389            }
390            TemplateChild::Node(child) => {
391                let built = expand_template(child, exp, ctx, &id);
392                node.children.extend(built);
393            }
394        }
395    }
396    node
397}
398
399/// Expand one macro node (children already native) into its template.
400/// Never inlined: its large frame stays off [`expand_tree`]'s recursion
401/// (VAPP-103: nested macros otherwise cost ~12 KB of stack per level).
402#[inline(never)]
403fn expand_macro(node: UiNode, macro_: &str, def: &MacroDef, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>) -> Result<UiNode, String> {
404    if depth > MAX_DEPTH {
405        return Err(format!("macro {macro_}: expansion deeper than {MAX_DEPTH}"));
406    }
407    let mut base_props = props_at(&node.props, None);
408    // Round 3: an `on.press` implies `pressable` on a macro that offers it (Row, Chip).
409    if node.on.as_ref().is_some_and(|o| o.contains_key("press")) && !base_props.contains_key("pressable") && view.components.get(macro_).is_some_and(|d| d.props.contains_key("pressable")) {
410        base_props.insert("pressable".into(), Value::Bool(true));
411    }
412    let variants = responsive_variants(&node.props);
413    let mut exp = Expansion { view, macro_, def, source: &node, base_props: base_props.clone(), variants, claimed: HashSet::new(), issues: Vec::new() };
414    let ctx = ExprContext { id: &node.id, props: &base_props, vars: IndexMap::new() };
415    let mut root = expand_template(&def.root, &mut exp, &ctx, &node.id)
416        .into_iter()
417        .next()
418        .ok_or_else(|| format!("macro {macro_}: the root template produced no node"))?;
419    let claimed = exp.claimed;
420    for issue in exp.issues {
421        if !issues.contains(&issue) {
422            issues.push(issue);
423        }
424    }
425    root.style = merge_style(root.style.as_ref(), node.style.as_ref()).filter(|s| !s.is_empty());
426    // VAPP-163: the style written ON the macro node wins over the macro's
427    // part recipes: its top-level keys (condition blocks included), SORTED,
428    // ride on the root's recipe as `authored`. Only the AUTHOR's keys count:
429    // a node an outer template drew (it carries a recipe) passes on just the
430    // keys its own author wrote, never template style.
431    let authored: Vec<String> = match &node.recipe {
432        Some(outer) => outer.authored.clone(),
433        None => {
434            let mut keys: Vec<String> = node.style.as_ref().map(|s| s.keys().cloned().collect()).unwrap_or_default();
435            keys.sort();
436            keys
437        }
438    };
439    if let Some(recipe) = root.recipe.as_mut() {
440        recipe.authored = authored;
441    }
442    if let Some(visible) = &node.visible {
443        root.visible = Some(visible.clone());
444    }
445    // The author's label/description (or an outer macro part's `$a11y`) win
446    // over the root part's own.
447    if let Some(Value::Object(accessibility)) = &node.accessibility {
448        let mut merged = spread(root.accessibility.as_ref());
449        for (k, v) in accessibility {
450            merged.insert(k.clone(), v.clone());
451        }
452        root.accessibility = Some(Value::Object(merged));
453    }
454    let mut remaining = root.on.take().unwrap_or_default();
455    for (event, action) in node.on.iter().flatten() {
456        if !claimed.contains(event) {
457            remaining.insert(event.clone(), action.clone());
458        }
459    }
460    root.on = (!remaining.is_empty()).then_some(remaining);
461    expand_tree(&mut root, view, depth + 1, issues)?;
462    Ok(root)
463}
464
465/// JavaScript truthiness (`if (value)`).
466fn js_truthy(value: &Value) -> bool {
467    match value {
468        Value::Null => false,
469        Value::Bool(b) => *b,
470        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
471        Value::String(s) => !s.is_empty(),
472        _ => true,
473    }
474}
475
476/// Expand IN PLACE, children and slots first (VAPP-103: no clone per
477/// level, a small frame per level: nested macros recurse through here).
478fn expand_tree(node: &mut UiNode, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>) -> Result<(), String> {
479    crate::deep(|| expand_node(node, view, depth, issues))
480}
481
482fn expand_node(node: &mut UiNode, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>) -> Result<(), String> {
483    for child in &mut node.children {
484        expand_tree(child, view, depth, issues)?;
485    }
486    if let Some(slots) = &mut node.slots {
487        for child in slots.values_mut() {
488            expand_tree(child, view, depth, issues)?;
489        }
490    }
491    if let Some(def) = view.macros.get(&node.component) {
492        if view.components.get(&node.component).is_some_and(|d| d.is_macro()) {
493            let component = node.component.clone();
494            let source = std::mem::replace(node, UiNode::new("", ""));
495            *node = expand_macro(source, &component, def, view, depth, issues)?;
496        }
497    }
498    Ok(())
499}
500
501/// The normalized tree with every macro (core + extensions) expanded into
502/// natives, recipes attached. Pure: the input is not mutated. `Err` when an
503/// expansion nests deeper than 16 macros. Expansion problems are dropped; see
504/// [`expand_macros_with_issues`].
505pub fn expand_macros(root: &UiNode, view: &CatalogView) -> Result<UiNode, String> {
506    expand_macros_with_issues(root, view).map(|(node, _)| node)
507}
508
509/// [`expand_macros`] plus the expansion problems (an author function that
510/// replaces a `$set` write-back), deduplicated per node: the TS
511/// `expandMacros(root, {issues})`.
512pub fn expand_macros_with_issues(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>), String> {
513    crate::roomy(|| expand_all(root, view))
514}
515
516fn expand_all(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>), String> {
517    let mut issues = Vec::new();
518    let mut node = root.clone();
519    expand_tree(&mut node, view, 0, &mut issues)?;
520    // Round 3: the tree guides of nested Rows come from their siblings, so
521    // they are filled AFTER the whole tree is native (`tree_guides`).
522    crate::tree_guides::apply_tree_guides(&mut node);
523    Ok((node, issues))
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use serde_json::json;
530
531    #[test]
532    fn responsive_values_pick_the_nearest_breakpoint_at_or_below() {
533        let v = json!({"base": 1, "md": 2, "xl": 4});
534        assert!(is_responsive_value(&v));
535        assert!(!is_responsive_value(&json!({"base": 1, "huge": 2})));
536        assert!(!is_responsive_value(&json!({"md": 2})));
537        assert_eq!(responsive_at(&v, None), Some(&json!(1)));
538        assert_eq!(responsive_at(&v, Some("sm")), Some(&json!(1)));
539        assert_eq!(responsive_at(&v, Some("md")), Some(&json!(2)));
540        assert_eq!(responsive_at(&v, Some("lg")), Some(&json!(2)));
541        assert_eq!(responsive_at(&v, Some("xl")), Some(&json!(4)));
542        assert_eq!(BREAKPOINTS.as_slice(), ["sm", "md", "lg", "xl"]);
543    }
544}