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_issues;
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>, options: &ReduceOptions) -> UiNode {
309    match expand_macros_with_issues(&node, &options.view) {
310        Ok((expanded, expansion_issues)) => {
311            issues.extend(expansion_issues);
312            expanded
313        }
314        Err(message) => {
315            issues.push(ReduceIssue { id: node.id.clone(), message });
316            node
317        }
318    }
319}
320
321/// Expand the root, then the lifted templates (one issue list); then the
322/// depth cap on the EXPANDED trees (VAPP-103 rfix: a macro adds levels, so
323/// `maxDepth` bounds the reduced tree a host serializes, not the authored
324/// one).
325fn finish(root: UiNode, mut issues: Vec<ReduceIssue>, lifted: IndexMap<String, UiNode>, options: &ReduceOptions) -> ReduceResult {
326    let mut root = if options.expand { expand(root, &mut issues, options) } else { root };
327    cap_depth(&mut root, 1, &mut issues, &options.catalog_id);
328    // R8 F51: ONE budget over the expanded trees (the root's, then every
329    // lifted template's): a macro's `$each` parts count.
330    let mut budget = Budget { left: limits::MAX_COMPONENTS, reported: issues.iter().any(|i| i.message == limits::components_issue()) };
331    budget.spend(&root.id, &mut issues);
332    cap_components(&mut root, &mut budget, &mut issues);
333    let templates = (!lifted.is_empty()).then(|| {
334        lifted
335            .into_iter()
336            .map(|(id, n)| {
337                let mut n = if options.expand { expand(n, &mut issues, options) } else { n };
338                cap_depth(&mut n, 1, &mut issues, &options.catalog_id);
339                budget.spend(&n.id, &mut issues);
340                cap_components(&mut n, &mut budget, &mut issues);
341                (id, n)
342            })
343            .collect()
344    });
345    ReduceResult { root, issues, templates }
346}
347
348/// R8 F51: the `maxComponents` budget over an EXPANDED tree (the flat
349/// builder counted the authored nodes; a macro's `$each` multiplies them:
350/// a Rating with `max: 10000` is 20,001 parts). Pre-order; a node past the
351/// budget is dropped with its subtree (children, then slots), the first
352/// refusal is the budget issue (once per surface).
353fn cap_components(node: &mut UiNode, budget: &mut Budget, issues: &mut Vec<ReduceIssue>) {
354    crate::deep(|| {
355        node.children.retain_mut(|child| {
356            if !budget.spend(&child.id, issues) {
357                return false;
358            }
359            cap_components(child, budget, issues);
360            true
361        });
362        if let Some(slots) = &mut node.slots {
363            slots.retain(|_, child| {
364                if !budget.spend(&child.id, issues) {
365                    return false;
366                }
367                cap_components(child, budget, issues);
368                true
369            });
370        }
371    })
372}
373
374/// A node at level `maxDepth + 1` (the root = 1; children and slots one
375/// level down) becomes the Unknown placeholder for its id and component,
376/// its subtree dropped, with the depth issue (once per id).
377fn cap_depth(node: &mut UiNode, depth: usize, issues: &mut Vec<ReduceIssue>, catalog_id: &str) {
378    if depth > limits::MAX_DEPTH {
379        if node.component != UNKNOWN_COMPONENT || !node.children.is_empty() || node.slots.is_some() {
380            let issue = ReduceIssue { id: node.id.clone(), message: limits::depth_issue() };
381            if !issues.contains(&issue) {
382                issues.push(issue);
383            }
384            *node = unknown(&node.id, &node.component, catalog_id);
385        }
386        return;
387    }
388    crate::deep(|| {
389        for child in &mut node.children {
390            cap_depth(child, depth + 1, issues, catalog_id);
391        }
392        for child in node.slots.iter_mut().flat_map(|s| s.values_mut()) {
393            cap_depth(child, depth + 1, issues, catalog_id);
394        }
395    })
396}
397
398struct Builder<'a> {
399    by_id: HashMap<&'a str, &'a FlatComponent>,
400    options: &'a ReduceOptions,
401    basic: bool,
402    visiting: HashSet<String>,
403    /// VAPP-103: every id already placed (a second place is refused).
404    placed: HashSet<String>,
405    /// R8 F52: the ids whose second place was already reported (a 4 MB
406    /// `children: ["x", "x", …]` costs ONE issue, not one per reference).
407    twice_reported: HashSet<String>,
408    budget: Budget,
409    issues: Vec<ReduceIssue>,
410}
411
412/// VAPP-103: the nodes one surface may still place (`maxComponents`).
413struct Budget {
414    left: usize,
415    reported: bool,
416}
417
418impl Budget {
419    fn new() -> Self {
420        Budget { left: limits::MAX_COMPONENTS, reported: false }
421    }
422
423    /// Take one node; the first refusal is an issue.
424    fn spend(&mut self, id: &str, issues: &mut Vec<ReduceIssue>) -> bool {
425        if self.left > 0 {
426            self.left -= 1;
427            return true;
428        }
429        if !self.reported {
430            issues.push(ReduceIssue { id: id.to_string(), message: limits::components_issue() });
431        }
432        self.reported = true;
433        false
434    }
435}
436
437impl Builder<'_> {
438    fn build(&mut self, id: &str, depth: usize) -> Option<UiNode> {
439        crate::deep(|| self.build_node(id, depth))
440    }
441
442    /// R8 F52: the Unknown placeholders for a missing or cyclic id are
443    /// placed nodes too, so they spend the budget BEFORE they are made: a
444    /// message whose children list repeats an unknown id a million times
445    /// stops at `maxComponents` placeholders (one budget issue), and a
446    /// second place of an id is reported once per id.
447    fn build_node(&mut self, id: &str, depth: usize) -> Option<UiNode> {
448        let catalog_id = self.options.catalog_id.as_str();
449        let flat = self.by_id.get(id).copied();
450        if let Some(flat) = flat {
451            // A cycle (the id is an ancestor) before a second place: the
452            // ancestor is in `placed` too.
453            if self.visiting.contains(id) {
454                if !self.budget.spend(id, &mut self.issues) {
455                    return None;
456                }
457                self.issues.push(ReduceIssue { id: id.to_string(), message: "cycle through this id".into() });
458                return Some(unknown(id, &flat.component, catalog_id));
459            }
460            if self.placed.contains(id) {
461                if self.twice_reported.insert(id.to_string()) {
462                    self.issues.push(ReduceIssue { id: id.to_string(), message: limits::USED_TWICE_ISSUE.into() });
463                }
464                return None;
465            }
466        }
467        if !self.budget.spend(id, &mut self.issues) {
468            return None;
469        }
470        let Some(flat) = flat else {
471            self.issues.push(ReduceIssue { id: id.to_string(), message: "no component with this id".into() });
472            return Some(unknown(id, &format!("#{id}"), catalog_id));
473        };
474        self.placed.insert(id.to_string());
475        if depth > limits::MAX_DEPTH {
476            self.issues.push(ReduceIssue { id: id.to_string(), message: limits::depth_issue() });
477            return Some(unknown(id, &flat.component, catalog_id));
478        }
479        self.visiting.insert(id.to_string());
480        let mut node;
481        if self.basic {
482            let by_id = &self.by_id;
483            let lookup = |id: &str| by_id.get(id).map(|f| (*f).clone());
484            match map_basic_component(flat, &lookup) {
485                None => {
486                    self.issues.push(ReduceIssue { id: id.to_string(), message: format!("basic component {} has no mapping", flat.component) });
487                    node = unknown(id, &flat.component, catalog_id);
488                }
489                Some(mapped) => {
490                    node = UiNode::new(id, mapped.component);
491                    node.props = mapped.props;
492                    node.children = mapped.children_ids.iter().filter_map(|c| self.build(c, depth + 1)).collect();
493                    node.template = mapped.template;
494                    node.style = mapped.style;
495                    node.on = mapped.on;
496                    for (slot, child_id) in &mapped.slots {
497                        if let Some(child) = self.build(child_id, depth + 1) {
498                            node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), child);
499                        }
500                    }
501                }
502            }
503        } else if !self.options.view.components.contains_key(&flat.component) {
504            self.issues.push(ReduceIssue { id: id.to_string(), message: format!("unknown component {}", flat.component) });
505            node = unknown(id, &flat.component, catalog_id);
506        } else {
507            node = UiNode::new(id, flat.component.clone());
508            node.props = own_props(flat);
509            match &flat.children {
510                Some(FlatChildren::Ids(ids)) => node.children = ids.iter().filter_map(|c| self.build(c, depth + 1)).collect(),
511                Some(t @ FlatChildren::Template { .. }) => node.template = t.template(),
512                None => {}
513            }
514            node.style = flat.style.clone();
515            if let Some(visible) = &flat.visible {
516                if valid_visible(visible) {
517                    node.visible = Some(visible.clone());
518                } else {
519                    self.issues.push(ReduceIssue { id: id.to_string(), message: VISIBLE_ISSUE.into() });
520                }
521            }
522            node.on = flat.on.clone();
523            for (slot, child_id) in flat.slots.iter().flatten() {
524                if let Some(child) = self.build(child_id, depth + 1) {
525                    node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), child);
526                }
527            }
528        }
529        if let Some(accessibility) = flat.accessibility.as_ref().filter(|a| js_truthy(a)) {
530            node.accessibility = Some(accessibility.clone());
531        }
532        self.visiting.remove(id);
533        validate_node(&node, id, self.options, true, &mut self.issues);
534        Some(node)
535    }
536}
537
538/// VAPP-103 rfix: the issues ONE component raises on its own (an unknown
539/// component or basic mapping, its props, `visible`, children it may not
540/// take, slots it lacks): what a streamed `updateComponents` reports at
541/// once while the tree reduces lazily. Tree issues (missing ids, cycles,
542/// depth, limits) come with the next reduce. `lookup` = the surface's
543/// components (a basic component consumes its children).
544pub fn component_issues(flat: &FlatComponent, options: &ReduceOptions, lookup: &dyn Fn(&str) -> Option<FlatComponent>) -> Vec<ReduceIssue> {
545    let mut issues = Vec::new();
546    let id = flat.id.as_str();
547    let stub = |cid: &str| UiNode::new(cid, UNKNOWN_COMPONENT);
548    let mut node;
549    if options.catalog_id == A2UI_BASIC_CATALOG_ID {
550        match map_basic_component(flat, lookup) {
551            None => {
552                issues.push(ReduceIssue { id: id.to_string(), message: format!("basic component {} has no mapping", flat.component) });
553                return issues;
554            }
555            Some(mapped) => {
556                node = UiNode::new(id, mapped.component);
557                node.props = mapped.props;
558                node.children = mapped.children_ids.iter().map(|c| stub(c)).collect();
559                node.template = mapped.template;
560                node.style = mapped.style;
561                node.on = mapped.on;
562                for (slot, child_id) in &mapped.slots {
563                    node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), stub(child_id));
564                }
565            }
566        }
567    } else if !options.view.components.contains_key(&flat.component) {
568        issues.push(ReduceIssue { id: id.to_string(), message: format!("unknown component {}", flat.component) });
569        return issues;
570    } else {
571        node = UiNode::new(id, flat.component.clone());
572        node.props = own_props(flat);
573        match &flat.children {
574            Some(FlatChildren::Ids(ids)) => node.children = ids.iter().map(|c| stub(c)).collect(),
575            Some(t @ FlatChildren::Template { .. }) => node.template = t.template(),
576            None => {}
577        }
578        node.style = flat.style.clone();
579        if let Some(visible) = &flat.visible {
580            if valid_visible(visible) {
581                node.visible = Some(visible.clone());
582            } else {
583                issues.push(ReduceIssue { id: id.to_string(), message: VISIBLE_ISSUE.into() });
584            }
585        }
586        node.on = flat.on.clone();
587        for (slot, child_id) in flat.slots.iter().flatten() {
588            node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), stub(child_id));
589        }
590    }
591    if let Some(accessibility) = flat.accessibility.as_ref().filter(|a| js_truthy(a)) {
592        node.accessibility = Some(accessibility.clone());
593    }
594    validate_node(&node, id, options, true, &mut issues);
595    issues
596}
597
598/// Reduce a flat component list. Children resolve from the root down, so
599/// components nothing references (a basic Button's consumed Text child) do
600/// not appear. A missing or cyclic reference becomes an `Unknown`
601/// placeholder plus an issue. VAPP-103: an id placed a second time (two
602/// parents, or one parent twice) renders at its first place only (`id used
603/// twice`), a node deeper than `maxDepth` is an `Unknown` placeholder, and
604/// past `maxComponents` nodes the rest is dropped (`catalog/limits.json`).
605pub fn reduce_surface(components: &[FlatComponent], options: &ReduceOptions) -> ReduceResult {
606    crate::roomy(|| reduce_flat(components, options))
607}
608
609fn reduce_flat(components: &[FlatComponent], options: &ReduceOptions) -> ReduceResult {
610    let mut issues = Vec::new();
611    let mut by_id: HashMap<&str, &FlatComponent> = HashMap::new();
612    for flat in components {
613        if by_id.contains_key(flat.id.as_str()) {
614            issues.push(ReduceIssue { id: flat.id.clone(), message: "duplicate id; the last definition wins".into() });
615        }
616        by_id.insert(flat.id.as_str(), flat);
617    }
618    let root_id = options.root_id.clone().unwrap_or_else(|| "root".to_string());
619    if !options.view.knows_catalog(&options.catalog_id) {
620        issues.push(ReduceIssue { id: root_id.clone(), message: format!("unsupported catalog {}", options.catalog_id) });
621    }
622    let mut builder = Builder {
623        by_id,
624        options,
625        basic: options.catalog_id == A2UI_BASIC_CATALOG_ID,
626        visiting: HashSet::new(),
627        placed: HashSet::new(),
628        twice_reported: HashSet::new(),
629        budget: Budget::new(),
630        issues,
631    };
632    let mut root = builder.build(&root_id, 1).unwrap_or_else(|| unknown(&root_id, &format!("#{root_id}"), &options.catalog_id));
633    let mut issues = std::mem::take(&mut builder.issues);
634    // A template id builds ONCE (its id is placed then); the lifting passes
635    // that ask again get the same node and issues.
636    let mut built: HashMap<String, (Option<UiNode>, Vec<ReduceIssue>)> = HashMap::new();
637    let lifted = lift_templates(&mut root, &mut issues, &mut |id| {
638        if let Some(done) = built.get(id) {
639            return done.clone();
640        }
641        let node = if builder.by_id.contains_key(id) && !builder.placed.contains(id) { builder.build(id, 1) } else { None };
642        let out = (node, std::mem::take(&mut builder.issues));
643        built.insert(id.to_string(), out.clone());
644        out
645    });
646    finish(root, issues, lifted, options)
647}
648
649/// The nested authoring form → a normalized tree (same validation and
650/// expansion as [`reduce_surface`]; `root_id` is ignored).
651pub fn reduce_nested(tree: &NestedNode, options: &ReduceOptions) -> ReduceResult {
652    crate::roomy(|| reduce_tree(tree, options))
653}
654
655fn reduce_tree(tree: &NestedNode, options: &ReduceOptions) -> ReduceResult {
656    let mut issues = Vec::new();
657    let mut budget = Budget::new();
658    let mut root = walk_nested(tree, options, &mut issues, &mut budget, 1).unwrap_or_else(|| unknown(&tree.id, &tree.component, &options.catalog_id));
659    let lifted = lift_templates(&mut root, &mut issues, &mut |_| (None, Vec::new()));
660    finish(root, issues, lifted, options)
661}
662
663fn walk_nested(n: &NestedNode, options: &ReduceOptions, issues: &mut Vec<ReduceIssue>, budget: &mut Budget, depth: usize) -> Option<UiNode> {
664    crate::deep(|| walk_nested_node(n, options, issues, budget, depth))
665}
666
667fn walk_nested_node(n: &NestedNode, options: &ReduceOptions, issues: &mut Vec<ReduceIssue>, budget: &mut Budget, depth: usize) -> Option<UiNode> {
668    if !budget.spend(&n.id, issues) {
669        return None;
670    }
671    if depth > limits::MAX_DEPTH {
672        issues.push(ReduceIssue { id: n.id.clone(), message: limits::depth_issue() });
673        return Some(unknown(&n.id, &n.component, &options.catalog_id));
674    }
675    if !options.view.components.contains_key(&n.component) {
676        issues.push(ReduceIssue { id: n.id.clone(), message: format!("unknown component {}", n.component) });
677        return Some(unknown(&n.id, &n.component, &options.catalog_id));
678    }
679    let mut node = UiNode::new(n.id.clone(), n.component.clone());
680    node.props = n.props.clone().unwrap_or_default();
681    node.children = n.children.iter().flatten().filter_map(|c| walk_nested(c, options, issues, budget, depth + 1)).collect();
682    node.style = n.style.clone();
683    if let Some(visible) = &n.visible {
684        if valid_visible(visible) {
685            node.visible = Some(visible.clone());
686        } else {
687            issues.push(ReduceIssue { id: n.id.clone(), message: VISIBLE_ISSUE.into() });
688        }
689    }
690    node.on = n.on.clone();
691    node.accessibility = n.accessibility.clone().filter(js_truthy);
692    node.template = n.template.clone();
693    if let Some(slots) = &n.slots {
694        let mut out = IndexMap::new();
695        for (slot, child) in slots {
696            if let Some(built) = walk_nested(child, options, issues, budget, depth + 1) {
697                out.insert(slot.clone(), built);
698            }
699        }
700        node.slots = Some(out);
701    }
702    validate_node(&node, &n.id, options, false, issues);
703    Some(node)
704}
705
706/// Pre-order ids of a tree (slots first, then children), the painters'
707/// accessibility order.
708pub fn preorder(root: &UiNode) -> Vec<String> {
709    let mut out = Vec::new();
710    root.walk(&mut |n| out.push(n.id.clone()));
711    out
712}