Skip to main content

eui_core/
reducer.rs

1//! The reducer: a surface's flat component list (A2UI `updateComponents`,
2//! core OR basic catalog OR an extension) → ONE normalized tree in the core
3//! vocabulary, macros expanded, every unknown component the explicit
4//! `Unknown` placeholder. Mirrors `src/reducer.ts`; the fixtures lock it.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9use indexmap::IndexMap;
10use serde_json::Value;
11
12use crate::basic_map::map_basic_component;
13use crate::limits;
14use crate::catalog::{CatalogView, A2UI_BASIC_CATALOG_ID, UNKNOWN_COMPONENT};
15use crate::macros::expand_macros_with_templates;
16use crate::types::{FlatChildren, FlatComponent, NestedNode, Props, ReduceIssue, UiNode};
17use crate::validate::validate_props;
18
19/// The keys of a flat component that are not props (round 1 adds `visible`).
20pub const RESERVED_KEYS: &[&str] = &["id", "component", "children", "slots", "on", "style", "visible", "accessibility", "template"];
21const RESERVED: &[&str] = RESERVED_KEYS;
22
23/// A `visible` value is a boolean or a dynamic value (a binding or a call).
24pub fn valid_visible(value: &Value) -> bool {
25    value.is_boolean() || crate::expr::is_dynamic(value)
26}
27
28/// A component's slot list admits a name when it lists it or lists `*`.
29pub fn slot_allowed(slots: Option<&Vec<String>>, name: &str) -> bool {
30    slots.is_some_and(|s| s.iter().any(|x| x == "*" || x == name))
31}
32
33const VISIBLE_ISSUE: &str = "visible: expected a boolean, a binding or a function call";
34
35#[derive(Debug, Clone)]
36pub struct ReduceOptions {
37    /// The surface's catalog: the core, the lite subset, the basic catalog or an extension's id.
38    pub catalog_id: String,
39    /// The core plus the registered extensions.
40    pub view: Arc<CatalogView>,
41    /// The root component id (A2UI: `root`).
42    pub root_id: Option<String>,
43    /// Expand macros (default true); false keeps the pre-expansion tree.
44    pub expand: bool,
45    /// Record prop validation issues (default true).
46    pub validate: bool,
47}
48
49impl ReduceOptions {
50    pub fn new(catalog_id: &str) -> Self {
51        ReduceOptions { catalog_id: catalog_id.to_string(), view: CatalogView::core(), root_id: None, expand: true, validate: true }
52    }
53
54    pub fn with_view(self, view: Arc<CatalogView>) -> Self {
55        ReduceOptions { view, ..self }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
60pub struct ReduceResult {
61    pub root: UiNode,
62    pub issues: Vec<ReduceIssue>,
63    /// Round 2 (docs/round-2-contract.md §4): the nodes a data `template`
64    /// renders per item, by component id, LIFTED out of the tree (a
65    /// template node listed as a child too never renders in place),
66    /// validated and expanded like the root, in discovery order. `None`
67    /// without templates.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub templates: Option<IndexMap<String, UiNode>>,
70}
71
72/// Builds a template id the trees do not hold (the flat path): the node and
73/// the issues its build raised.
74type BuildMissing<'a> = dyn FnMut(&str) -> (Option<UiNode>, Vec<ReduceIssue>) + 'a;
75
76/// Lift every node a `template.component` names out of the trees (children
77/// and slots, any depth, the root excepted) into the template table; ids
78/// no tree holds are built with `build_missing` (the flat path: the node
79/// and the issues its build raised), else reported. Nested templates (a
80/// template inside a template node) are lifted too. A template that would
81/// instantiate itself (its owner, an ancestor of its owner, the root, or
82/// through other templates) is an issue (`template: cycle through this
83/// id`) and stays in place: each round puts the cyclic ones back (which
84/// may give their new ancestors edges) and lifts again, until none is
85/// left. `src/reducer.ts liftTemplates`.
86fn lift_templates(root: &mut UiNode, issues: &mut Vec<ReduceIssue>, build_missing: &mut BuildMissing) -> IndexMap<String, UiNode> {
87    let original = root.clone();
88    // Cyclic ids: kept in the tree (never stripped, never built) or, when
89    // built, built again (their subtree still names templates) but dropped.
90    let mut in_place: Vec<String> = Vec::new();
91    let mut dropped: Vec<String> = Vec::new();
92    let mut cycles: Vec<ReduceIssue> = Vec::new();
93    loop {
94        *root = original.clone();
95        let mut pass_issues = Vec::new();
96        let pass = lift_pass(root, &mut pass_issues, build_missing, &in_place);
97        let lifted: Vec<&String> = pass.order.iter().filter(|id| pass.found.contains_key(*id) && !in_place.contains(id) && !dropped.contains(id)).collect();
98        // Edges: a lifted template → the lifted templates its subtree's owners name.
99        fn walk(n: &UiNode, lifted: &[&String], out: &mut Vec<String>) {
100            if let Some(t) = &n.template {
101                if lifted.iter().any(|l| **l == t.component) && !out.contains(&t.component) {
102                    out.push(t.component.clone());
103                }
104            }
105            for slot in n.slots.iter().flat_map(|s| s.values()) {
106                walk(slot, lifted, out);
107            }
108            for c in &n.children {
109                walk(c, lifted, out);
110            }
111        }
112        let deps: HashMap<&str, Vec<String>> = lifted
113            .iter()
114            .map(|id| {
115                let mut out = Vec::new();
116                walk(&pass.found[id.as_str()], &lifted, &mut out);
117                (id.as_str(), out)
118            })
119            .collect();
120        let reaches_itself = |from: &str| {
121            let mut seen: HashSet<&str> = HashSet::new();
122            let mut stack: Vec<&str> = deps.get(from).map(|d| d.iter().map(String::as_str).collect()).unwrap_or_default();
123            while let Some(id) = stack.pop() {
124                if id == from {
125                    return true;
126                }
127                if seen.insert(id) {
128                    stack.extend(deps.get(id).into_iter().flatten().map(String::as_str));
129                }
130            }
131            false
132        };
133        let cyclic: Vec<String> = lifted.iter().filter(|id| reaches_itself(id)).map(|id| id.to_string()).collect();
134        if cyclic.is_empty() {
135            issues.extend(pass_issues);
136            if pass.root_named {
137                issues.push(ReduceIssue { id: original.id.clone(), message: "template: cycle through this id".into() });
138            }
139            issues.extend(cycles);
140            let mut found = pass.found;
141            return pass.order.iter().filter(|id| lifted.contains(id)).filter_map(|id| found.shift_remove(id).map(|n| (id.clone(), n))).collect();
142        }
143        for id in cyclic {
144            if pass.built.contains(&id) {
145                dropped.push(id.clone());
146            } else {
147                in_place.push(id.clone());
148            }
149            cycles.push(ReduceIssue { id, message: "template: cycle through this id".into() });
150        }
151    }
152}
153
154/// One lifting pass of [`lift_templates`]: `in_place` ids are never
155/// stripped nor built.
156struct LiftPass {
157    /// Every template node found or built, by id.
158    found: IndexMap<String, UiNode>,
159    /// The template ids in discovery order.
160    order: Vec<String>,
161    /// The ids `build_missing` built.
162    built: Vec<String>,
163    /// A template names the root.
164    root_named: bool,
165}
166
167fn lift_pass(root: &mut UiNode, issues: &mut Vec<ReduceIssue>, build_missing: &mut BuildMissing, in_place: &[String]) -> LiftPass {
168    fn collect(n: &UiNode, root_id: &str, want: &mut Vec<String>, root_named: &mut bool) {
169        if let Some(t) = &n.template {
170            if t.component == root_id {
171                *root_named = true;
172            } else if !want.contains(&t.component) {
173                want.push(t.component.clone());
174            }
175        }
176        for slot in n.slots.iter().flat_map(|s| s.values()) {
177            collect(slot, root_id, want, root_named);
178        }
179        for c in &n.children {
180            collect(c, root_id, want, root_named);
181        }
182    }
183    fn strip(n: &mut UiNode, want: &[String], found: &mut IndexMap<String, UiNode>) {
184        let children = std::mem::take(&mut n.children);
185        for c in children {
186            if want.contains(&c.id) {
187                if !found.contains_key(&c.id) {
188                    found.insert(c.id.clone(), c);
189                }
190            } else {
191                n.children.push(c);
192            }
193        }
194        if let Some(slots) = &mut n.slots {
195            let names: Vec<String> = slots.iter().filter(|(_, v)| want.contains(&v.id)).map(|(k, _)| k.clone()).collect();
196            for name in names {
197                if let Some(slot) = slots.shift_remove(&name) {
198                    if !found.contains_key(&slot.id) {
199                        found.insert(slot.id.clone(), slot);
200                    }
201                }
202            }
203            if slots.is_empty() {
204                n.slots = None;
205            }
206        }
207        for slot in n.slots.iter_mut().flat_map(|s| s.values_mut()) {
208            strip(slot, want, found);
209        }
210        for c in &mut n.children {
211            strip(c, want, found);
212        }
213    }
214    let root_id = root.id.clone();
215    let mut want: Vec<String> = Vec::new();
216    let mut found: IndexMap<String, UiNode> = IndexMap::new();
217    let mut missing: Vec<String> = Vec::new();
218    let mut built: Vec<String> = Vec::new();
219    let mut root_named = false;
220    collect(root, &root_id, &mut want, &mut root_named);
221    let mut before = (usize::MAX, 0, 0);
222    while before != (want.len(), found.len(), missing.len()) {
223        before = (want.len(), found.len(), missing.len());
224        let strippable: Vec<String> = want.iter().filter(|id| !in_place.contains(id)).cloned().collect();
225        strip(root, &strippable, &mut found);
226        loop {
227            let n = found.len();
228            for i in 0..n {
229                let mut node = std::mem::replace(&mut found[i], UiNode::new("", ""));
230                strip(&mut node, &strippable, &mut found);
231                found[i] = node;
232            }
233            if found.len() == n {
234                break;
235            }
236        }
237        for id in want.clone() {
238            if found.contains_key(&id) || missing.contains(&id) || in_place.contains(&id) {
239                continue;
240            }
241            let (node, raised) = build_missing(&id);
242            issues.extend(raised);
243            match node {
244                Some(node) => {
245                    built.push(id.clone());
246                    found.insert(id, node);
247                }
248                None => {
249                    missing.push(id.clone());
250                    issues.push(ReduceIssue { id, message: "template: no component with this id".into() });
251                }
252            }
253        }
254        for node in found.values() {
255            collect(node, &root_id, &mut want, &mut root_named);
256        }
257    }
258    LiftPass { found, order: want, built, root_named }
259}
260
261fn unknown(id: &str, component: &str, catalog_id: &str) -> UiNode {
262    let mut node = UiNode::new(id, UNKNOWN_COMPONENT);
263    node.props.insert("component".into(), Value::String(component.to_string()));
264    node.props.insert("catalogId".into(), Value::String(catalog_id.to_string()));
265    node
266}
267
268fn own_props(flat: &FlatComponent) -> Props {
269    flat.rest.iter().filter(|(k, _)| !RESERVED.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()
270}
271
272/// JavaScript truthiness (`if (value)`).
273fn js_truthy(value: &Value) -> bool {
274    match value {
275        Value::Null => false,
276        Value::Bool(b) => *b,
277        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
278        Value::String(s) => !s.is_empty(),
279        _ => true,
280    }
281}
282
283/// `skip_unknown`: the flat path never validates a placeholder; the nested
284/// path validates whatever the author wrote (an authored `Unknown` too).
285/// Never inlined: its frame stays off the recursive builders (VAPP-103).
286#[inline(never)]
287fn validate_node(node: &UiNode, id: &str, options: &ReduceOptions, skip_unknown: bool, issues: &mut Vec<ReduceIssue>) {
288    if !options.validate || (skip_unknown && node.component == UNKNOWN_COMPONENT) {
289        return;
290    }
291    let Some(def) = options.view.components.get(&node.component) else { return };
292    for issue in validate_props(def, &node.props, "props", &options.view) {
293        issues.push(ReduceIssue { id: id.to_string(), message: format!("{}: {}", issue.path, issue.message) });
294    }
295    if def.children == "none" && !node.children.is_empty() {
296        issues.push(ReduceIssue { id: id.to_string(), message: format!("{} takes no children", node.component) });
297    }
298    for slot in node.slots.iter().flat_map(|s| s.keys()) {
299        if !slot_allowed(def.slots.as_ref(), slot) {
300            issues.push(ReduceIssue { id: id.to_string(), message: format!("slots.{slot}: {} has no such slot", node.component) });
301        }
302    }
303    for issue in crate::validate::validate_node_in(node, &options.view) {
304        issues.push(ReduceIssue { id: id.to_string(), message: format!("{}: {}", issue.path, issue.message) });
305    }
306}
307
308fn expand(node: UiNode, issues: &mut Vec<ReduceIssue>, built: &mut Vec<UiNode>, options: &ReduceOptions) -> UiNode {
309    match expand_macros_with_templates(&node, &options.view) {
310        Ok((expanded, expansion_issues, templates)) => {
311            issues.extend(expansion_issues);
312            built.extend(templates);
313            expanded
314        }
315        Err(message) => {
316            issues.push(ReduceIssue { id: node.id.clone(), message });
317            node
318        }
319    }
320}
321
322/// Expand the root, then the lifted templates (one issue list); then the
323/// depth cap on the EXPANDED trees (VAPP-103 rfix: a macro adds levels, so
324/// `maxDepth` bounds the reduced tree a host serializes, not the authored
325/// one).
326fn finish(root: UiNode, mut issues: Vec<ReduceIssue>, lifted: IndexMap<String, UiNode>, options: &ReduceOptions) -> ReduceResult {
327    // eApps U3: the items a macro's `$template` parts built join the lifted
328    // templates (after them, in build order: the root's first).
329    let mut built: Vec<UiNode> = Vec::new();
330    let mut root = if options.expand { expand(root, &mut issues, &mut built, options) } else { root };
331    cap_depth(&mut root, 1, &mut issues, &options.catalog_id);
332    // R8 F51: ONE budget over the expanded trees (the root's, then every
333    // lifted template's): a macro's `$each` parts count.
334    let mut budget = Budget { left: limits::MAX_COMPONENTS, reported: issues.iter().any(|i| i.message == limits::components_issue()) };
335    budget.spend(&root.id, &mut issues);
336    cap_components(&mut root, &mut budget, &mut issues);
337    let mut templates: IndexMap<String, UiNode> = lifted
338        .into_iter()
339        .map(|(id, n)| {
340            let mut n = if options.expand { expand(n, &mut issues, &mut built, options) } else { n };
341            cap_depth(&mut n, 1, &mut issues, &options.catalog_id);
342            budget.spend(&n.id, &mut issues);
343            cap_components(&mut n, &mut budget, &mut issues);
344            (id, n)
345        })
346        .collect();
347    for mut n in built {
348        if templates.contains_key(&n.id) {
349            continue;
350        }
351        cap_depth(&mut n, 1, &mut issues, &options.catalog_id);
352        budget.spend(&n.id, &mut issues);
353        cap_components(&mut n, &mut budget, &mut issues);
354        templates.insert(n.id.clone(), n);
355    }
356    let templates = (!templates.is_empty()).then_some(templates);
357    ReduceResult { root, issues, templates }
358}
359
360/// R8 F51: the `maxComponents` budget over an EXPANDED tree (the flat
361/// builder counted the authored nodes; a macro's `$each` multiplies them:
362/// a Rating with `max: 10000` is 20,001 parts). Pre-order; a node past the
363/// budget is dropped with its subtree (children, then slots), the first
364/// refusal is the budget issue (once per surface).
365fn cap_components(node: &mut UiNode, budget: &mut Budget, issues: &mut Vec<ReduceIssue>) {
366    crate::deep(|| {
367        node.children.retain_mut(|child| {
368            if !budget.spend(&child.id, issues) {
369                return false;
370            }
371            cap_components(child, budget, issues);
372            true
373        });
374        if let Some(slots) = &mut node.slots {
375            slots.retain(|_, child| {
376                if !budget.spend(&child.id, issues) {
377                    return false;
378                }
379                cap_components(child, budget, issues);
380                true
381            });
382        }
383    })
384}
385
386/// A node at level `maxDepth + 1` (the root = 1; children and slots one
387/// level down) becomes the Unknown placeholder for its id and component,
388/// its subtree dropped, with the depth issue (once per id).
389fn cap_depth(node: &mut UiNode, depth: usize, issues: &mut Vec<ReduceIssue>, catalog_id: &str) {
390    if depth > limits::MAX_DEPTH {
391        if node.component != UNKNOWN_COMPONENT || !node.children.is_empty() || node.slots.is_some() {
392            let issue = ReduceIssue { id: node.id.clone(), message: limits::depth_issue() };
393            if !issues.contains(&issue) {
394                issues.push(issue);
395            }
396            *node = unknown(&node.id, &node.component, catalog_id);
397        }
398        return;
399    }
400    crate::deep(|| {
401        for child in &mut node.children {
402            cap_depth(child, depth + 1, issues, catalog_id);
403        }
404        for child in node.slots.iter_mut().flat_map(|s| s.values_mut()) {
405            cap_depth(child, depth + 1, issues, catalog_id);
406        }
407    })
408}
409
410struct Builder<'a> {
411    by_id: HashMap<&'a str, &'a FlatComponent>,
412    options: &'a ReduceOptions,
413    basic: bool,
414    visiting: HashSet<String>,
415    /// VAPP-103: every id already placed (a second place is refused).
416    placed: HashSet<String>,
417    /// R8 F52: the ids whose second place was already reported (a 4 MB
418    /// `children: ["x", "x", …]` costs ONE issue, not one per reference).
419    twice_reported: HashSet<String>,
420    budget: Budget,
421    issues: Vec<ReduceIssue>,
422}
423
424/// VAPP-103: the nodes one surface may still place (`maxComponents`).
425struct Budget {
426    left: usize,
427    reported: bool,
428}
429
430impl Budget {
431    fn new() -> Self {
432        Budget { left: limits::MAX_COMPONENTS, reported: false }
433    }
434
435    /// Take one node; the first refusal is an issue.
436    fn spend(&mut self, id: &str, issues: &mut Vec<ReduceIssue>) -> bool {
437        if self.left > 0 {
438            self.left -= 1;
439            return true;
440        }
441        if !self.reported {
442            issues.push(ReduceIssue { id: id.to_string(), message: limits::components_issue() });
443        }
444        self.reported = true;
445        false
446    }
447}
448
449impl Builder<'_> {
450    fn build(&mut self, id: &str, depth: usize) -> Option<UiNode> {
451        crate::deep(|| self.build_node(id, depth))
452    }
453
454    /// R8 F52: the Unknown placeholders for a missing or cyclic id are
455    /// placed nodes too, so they spend the budget BEFORE they are made: a
456    /// message whose children list repeats an unknown id a million times
457    /// stops at `maxComponents` placeholders (one budget issue), and a
458    /// second place of an id is reported once per id.
459    fn build_node(&mut self, id: &str, depth: usize) -> Option<UiNode> {
460        let catalog_id = self.options.catalog_id.as_str();
461        let flat = self.by_id.get(id).copied();
462        if let Some(flat) = flat {
463            // A cycle (the id is an ancestor) before a second place: the
464            // ancestor is in `placed` too.
465            if self.visiting.contains(id) {
466                if !self.budget.spend(id, &mut self.issues) {
467                    return None;
468                }
469                self.issues.push(ReduceIssue { id: id.to_string(), message: "cycle through this id".into() });
470                return Some(unknown(id, &flat.component, catalog_id));
471            }
472            if self.placed.contains(id) {
473                if self.twice_reported.insert(id.to_string()) {
474                    self.issues.push(ReduceIssue { id: id.to_string(), message: limits::USED_TWICE_ISSUE.into() });
475                }
476                return None;
477            }
478        }
479        if !self.budget.spend(id, &mut self.issues) {
480            return None;
481        }
482        let Some(flat) = flat else {
483            self.issues.push(ReduceIssue { id: id.to_string(), message: "no component with this id".into() });
484            return Some(unknown(id, &format!("#{id}"), catalog_id));
485        };
486        self.placed.insert(id.to_string());
487        if depth > limits::MAX_DEPTH {
488            self.issues.push(ReduceIssue { id: id.to_string(), message: limits::depth_issue() });
489            return Some(unknown(id, &flat.component, catalog_id));
490        }
491        self.visiting.insert(id.to_string());
492        let mut node;
493        if self.basic {
494            let by_id = &self.by_id;
495            let lookup = |id: &str| by_id.get(id).map(|f| (*f).clone());
496            match map_basic_component(flat, &lookup) {
497                None => {
498                    self.issues.push(ReduceIssue { id: id.to_string(), message: format!("basic component {} has no mapping", flat.component) });
499                    node = unknown(id, &flat.component, catalog_id);
500                }
501                Some(mapped) => {
502                    node = UiNode::new(id, mapped.component);
503                    node.props = mapped.props;
504                    node.children = mapped.children_ids.iter().filter_map(|c| self.build(c, depth + 1)).collect();
505                    node.template = mapped.template;
506                    node.style = mapped.style;
507                    node.on = mapped.on;
508                    for (slot, child_id) in &mapped.slots {
509                        if let Some(child) = self.build(child_id, depth + 1) {
510                            node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), child);
511                        }
512                    }
513                }
514            }
515        } else if !self.options.view.components.contains_key(&flat.component) {
516            self.issues.push(ReduceIssue { id: id.to_string(), message: format!("unknown component {}", flat.component) });
517            node = unknown(id, &flat.component, catalog_id);
518        } else {
519            node = UiNode::new(id, flat.component.clone());
520            node.props = own_props(flat);
521            match &flat.children {
522                Some(FlatChildren::Ids(ids)) => node.children = ids.iter().filter_map(|c| self.build(c, depth + 1)).collect(),
523                Some(t @ FlatChildren::Template { .. }) => node.template = t.template(),
524                None => {}
525            }
526            node.style = flat.style.clone();
527            if let Some(visible) = &flat.visible {
528                if valid_visible(visible) {
529                    node.visible = Some(visible.clone());
530                } else {
531                    self.issues.push(ReduceIssue { id: id.to_string(), message: VISIBLE_ISSUE.into() });
532                }
533            }
534            node.on = flat.on.clone();
535            for (slot, child_id) in flat.slots.iter().flatten() {
536                if let Some(child) = self.build(child_id, depth + 1) {
537                    node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), child);
538                }
539            }
540        }
541        if let Some(accessibility) = flat.accessibility.as_ref().filter(|a| js_truthy(a)) {
542            node.accessibility = Some(accessibility.clone());
543        }
544        self.visiting.remove(id);
545        validate_node(&node, id, self.options, true, &mut self.issues);
546        Some(node)
547    }
548}
549
550/// VAPP-103 rfix: the issues ONE component raises on its own (an unknown
551/// component or basic mapping, its props, `visible`, children it may not
552/// take, slots it lacks): what a streamed `updateComponents` reports at
553/// once while the tree reduces lazily. Tree issues (missing ids, cycles,
554/// depth, limits) come with the next reduce. `lookup` = the surface's
555/// components (a basic component consumes its children).
556pub fn component_issues(flat: &FlatComponent, options: &ReduceOptions, lookup: &dyn Fn(&str) -> Option<FlatComponent>) -> Vec<ReduceIssue> {
557    let mut issues = Vec::new();
558    let id = flat.id.as_str();
559    let stub = |cid: &str| UiNode::new(cid, UNKNOWN_COMPONENT);
560    let mut node;
561    if options.catalog_id == A2UI_BASIC_CATALOG_ID {
562        match map_basic_component(flat, lookup) {
563            None => {
564                issues.push(ReduceIssue { id: id.to_string(), message: format!("basic component {} has no mapping", flat.component) });
565                return issues;
566            }
567            Some(mapped) => {
568                node = UiNode::new(id, mapped.component);
569                node.props = mapped.props;
570                node.children = mapped.children_ids.iter().map(|c| stub(c)).collect();
571                node.template = mapped.template;
572                node.style = mapped.style;
573                node.on = mapped.on;
574                for (slot, child_id) in &mapped.slots {
575                    node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), stub(child_id));
576                }
577            }
578        }
579    } else if !options.view.components.contains_key(&flat.component) {
580        issues.push(ReduceIssue { id: id.to_string(), message: format!("unknown component {}", flat.component) });
581        return issues;
582    } else {
583        node = UiNode::new(id, flat.component.clone());
584        node.props = own_props(flat);
585        match &flat.children {
586            Some(FlatChildren::Ids(ids)) => node.children = ids.iter().map(|c| stub(c)).collect(),
587            Some(t @ FlatChildren::Template { .. }) => node.template = t.template(),
588            None => {}
589        }
590        node.style = flat.style.clone();
591        if let Some(visible) = &flat.visible {
592            if valid_visible(visible) {
593                node.visible = Some(visible.clone());
594            } else {
595                issues.push(ReduceIssue { id: id.to_string(), message: VISIBLE_ISSUE.into() });
596            }
597        }
598        node.on = flat.on.clone();
599        for (slot, child_id) in flat.slots.iter().flatten() {
600            node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), stub(child_id));
601        }
602    }
603    if let Some(accessibility) = flat.accessibility.as_ref().filter(|a| js_truthy(a)) {
604        node.accessibility = Some(accessibility.clone());
605    }
606    validate_node(&node, id, options, true, &mut issues);
607    issues
608}
609
610/// Reduce a flat component list. Children resolve from the root down, so
611/// components nothing references (a basic Button's consumed Text child) do
612/// not appear. A missing or cyclic reference becomes an `Unknown`
613/// placeholder plus an issue. VAPP-103: an id placed a second time (two
614/// parents, or one parent twice) renders at its first place only (`id used
615/// twice`), a node deeper than `maxDepth` is an `Unknown` placeholder, and
616/// past `maxComponents` nodes the rest is dropped (`catalog/limits.json`).
617pub fn reduce_surface(components: &[FlatComponent], options: &ReduceOptions) -> ReduceResult {
618    crate::roomy(|| reduce_flat(components, options))
619}
620
621fn reduce_flat(components: &[FlatComponent], options: &ReduceOptions) -> ReduceResult {
622    let mut issues = Vec::new();
623    let mut by_id: HashMap<&str, &FlatComponent> = HashMap::new();
624    for flat in components {
625        if by_id.contains_key(flat.id.as_str()) {
626            issues.push(ReduceIssue { id: flat.id.clone(), message: "duplicate id; the last definition wins".into() });
627        }
628        by_id.insert(flat.id.as_str(), flat);
629    }
630    let root_id = options.root_id.clone().unwrap_or_else(|| "root".to_string());
631    if !options.view.knows_catalog(&options.catalog_id) {
632        issues.push(ReduceIssue { id: root_id.clone(), message: format!("unsupported catalog {}", options.catalog_id) });
633    }
634    let mut builder = Builder {
635        by_id,
636        options,
637        basic: options.catalog_id == A2UI_BASIC_CATALOG_ID,
638        visiting: HashSet::new(),
639        placed: HashSet::new(),
640        twice_reported: HashSet::new(),
641        budget: Budget::new(),
642        issues,
643    };
644    let mut root = builder.build(&root_id, 1).unwrap_or_else(|| unknown(&root_id, &format!("#{root_id}"), &options.catalog_id));
645    let mut issues = std::mem::take(&mut builder.issues);
646    // A template id builds ONCE (its id is placed then); the lifting passes
647    // that ask again get the same node and issues.
648    let mut built: HashMap<String, (Option<UiNode>, Vec<ReduceIssue>)> = HashMap::new();
649    let lifted = lift_templates(&mut root, &mut issues, &mut |id| {
650        if let Some(done) = built.get(id) {
651            return done.clone();
652        }
653        let node = if builder.by_id.contains_key(id) && !builder.placed.contains(id) { builder.build(id, 1) } else { None };
654        let out = (node, std::mem::take(&mut builder.issues));
655        built.insert(id.to_string(), out.clone());
656        out
657    });
658    finish(root, issues, lifted, options)
659}
660
661/// The nested authoring form → a normalized tree (same validation and
662/// expansion as [`reduce_surface`]; `root_id` is ignored).
663pub fn reduce_nested(tree: &NestedNode, options: &ReduceOptions) -> ReduceResult {
664    crate::roomy(|| reduce_tree(tree, options))
665}
666
667fn reduce_tree(tree: &NestedNode, options: &ReduceOptions) -> ReduceResult {
668    let mut issues = Vec::new();
669    let mut budget = Budget::new();
670    let mut root = walk_nested(tree, options, &mut issues, &mut budget, 1).unwrap_or_else(|| unknown(&tree.id, &tree.component, &options.catalog_id));
671    let lifted = lift_templates(&mut root, &mut issues, &mut |_| (None, Vec::new()));
672    finish(root, issues, lifted, options)
673}
674
675fn walk_nested(n: &NestedNode, options: &ReduceOptions, issues: &mut Vec<ReduceIssue>, budget: &mut Budget, depth: usize) -> Option<UiNode> {
676    crate::deep(|| walk_nested_node(n, options, issues, budget, depth))
677}
678
679fn walk_nested_node(n: &NestedNode, options: &ReduceOptions, issues: &mut Vec<ReduceIssue>, budget: &mut Budget, depth: usize) -> Option<UiNode> {
680    if !budget.spend(&n.id, issues) {
681        return None;
682    }
683    if depth > limits::MAX_DEPTH {
684        issues.push(ReduceIssue { id: n.id.clone(), message: limits::depth_issue() });
685        return Some(unknown(&n.id, &n.component, &options.catalog_id));
686    }
687    if !options.view.components.contains_key(&n.component) {
688        issues.push(ReduceIssue { id: n.id.clone(), message: format!("unknown component {}", n.component) });
689        return Some(unknown(&n.id, &n.component, &options.catalog_id));
690    }
691    let mut node = UiNode::new(n.id.clone(), n.component.clone());
692    node.props = n.props.clone().unwrap_or_default();
693    node.children = n.children.iter().flatten().filter_map(|c| walk_nested(c, options, issues, budget, depth + 1)).collect();
694    node.style = n.style.clone();
695    if let Some(visible) = &n.visible {
696        if valid_visible(visible) {
697            node.visible = Some(visible.clone());
698        } else {
699            issues.push(ReduceIssue { id: n.id.clone(), message: VISIBLE_ISSUE.into() });
700        }
701    }
702    node.on = n.on.clone();
703    node.accessibility = n.accessibility.clone().filter(js_truthy);
704    node.template = n.template.clone();
705    if let Some(slots) = &n.slots {
706        let mut out = IndexMap::new();
707        for (slot, child) in slots {
708            if let Some(built) = walk_nested(child, options, issues, budget, depth + 1) {
709                out.insert(slot.clone(), built);
710            }
711        }
712        node.slots = Some(out);
713    }
714    validate_node(&node, &n.id, options, false, issues);
715    Some(node)
716}
717
718/// Pre-order ids of a tree (slots first, then children), the painters'
719/// accessibility order.
720pub fn preorder(root: &UiNode) -> Vec<String> {
721    let mut out = Vec::new();
722    root.walk(&mut |n| out.push(n.id.clone()));
723    out
724}