Skip to main content

presolve_compiler/
component_instance.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4    ComponentInstanceId, ComponentInvocationEntity, ComponentInvocationId,
5    ComponentInvocationResolutionStatus, ComponentNode, ComponentRootId,
6    ComponentStructuralRegionId, SemanticId, SourceProvenance, TemplateSemanticEntity,
7    TemplateSemanticKind,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ComponentBuildRootKind {
12    Route,
13    BuildEntry,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ComponentBuildRoot {
18    pub id: ComponentRootId,
19    pub component: SemanticId,
20    pub kind: ComponentBuildRootKind,
21    pub route_path: Option<String>,
22    pub provenance: SourceProvenance,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ComponentInstanceStatus {
27    Planned,
28    StructuralTemplate,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ComponentInstance {
33    pub id: ComponentInstanceId,
34    pub component: SemanticId,
35    pub invocation: Option<ComponentInvocationId>,
36    pub parent_instance: Option<ComponentInstanceId>,
37    pub owner_root: ComponentRootId,
38    pub structural_region: Option<ComponentStructuralRegionId>,
39    pub depth: usize,
40    pub status: ComponentInstanceStatus,
41    pub provenance: SourceProvenance,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
45pub enum BlockedComponentInstanceReason {
46    UnresolvedInvocation,
47    UnsupportedDynamicInvocation,
48    InvalidTarget,
49    CompositionCycleBoundary,
50    InvalidParentPlan,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct BlockedComponentInstancePlan {
55    pub id: ComponentInstanceId,
56    pub invocation: ComponentInvocationId,
57    pub parent_instance: ComponentInstanceId,
58    pub owner_root: ComponentRootId,
59    pub target_component: Option<SemanticId>,
60    pub structural_region: Option<ComponentStructuralRegionId>,
61    pub depth: usize,
62    pub reason: BlockedComponentInstanceReason,
63    pub provenance: SourceProvenance,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub struct ComponentInstancePlan {
68    pub roots: BTreeMap<ComponentRootId, ComponentBuildRoot>,
69    pub instances: BTreeMap<ComponentInstanceId, ComponentInstance>,
70    pub blocked: BTreeMap<ComponentInstanceId, BlockedComponentInstancePlan>,
71}
72
73/// Plan the finite statically reachable component instance topology.
74#[must_use]
75pub fn plan_component_instances(
76    components: &[ComponentNode],
77    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
78    template_entities: &[TemplateSemanticEntity],
79    provenance: &BTreeMap<SemanticId, SourceProvenance>,
80) -> ComponentInstancePlan {
81    plan_component_instances_with_virtual_invocations(
82        components,
83        invocations,
84        &BTreeMap::new(),
85        template_entities,
86        provenance,
87    )
88}
89
90/// Plans authored and compiler-issued virtual component edges through one
91/// instance topology. Virtual edges are admitted only by file-route lowering.
92#[must_use]
93pub fn plan_component_instances_with_virtual_invocations(
94    components: &[ComponentNode],
95    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
96    virtual_invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
97    template_entities: &[TemplateSemanticEntity],
98    provenance: &BTreeMap<SemanticId, SourceProvenance>,
99) -> ComponentInstancePlan {
100    let mut all_invocations = invocations.clone();
101    all_invocations.extend(virtual_invocations.clone());
102    let roots = collect_build_roots(components, &all_invocations, provenance);
103    let mut plan = ComponentInstancePlan {
104        roots,
105        instances: BTreeMap::new(),
106        blocked: BTreeMap::new(),
107    };
108
109    for root in plan.roots.values().cloned().collect::<Vec<_>>() {
110        let root_instance_id = ComponentInstanceId::for_root(&root.id);
111        plan.instances.insert(
112            root_instance_id.clone(),
113            ComponentInstance {
114                id: root_instance_id.clone(),
115                component: root.component.clone(),
116                invocation: None,
117                parent_instance: None,
118                owner_root: root.id.clone(),
119                structural_region: None,
120                depth: 0,
121                status: ComponentInstanceStatus::Planned,
122                provenance: root.provenance.clone(),
123            },
124        );
125        expand_component_instances(
126            &root.component,
127            &root_instance_id,
128            &root.id,
129            1,
130            std::slice::from_ref(&root.component),
131            components,
132            &all_invocations,
133            template_entities,
134            &mut plan,
135        );
136    }
137
138    plan
139}
140
141fn collect_build_roots(
142    components: &[ComponentNode],
143    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
144    provenance: &BTreeMap<SemanticId, SourceProvenance>,
145) -> BTreeMap<ComponentRootId, ComponentBuildRoot> {
146    let routed = components
147        .iter()
148        .filter(|component| component.element_name.is_some() && component.route_path.is_some())
149        .collect::<Vec<_>>();
150    let mut root_components = if routed.is_empty() {
151        let incoming = invocations
152            .values()
153            .filter(|invocation| invocation.status == ComponentInvocationResolutionStatus::Resolved)
154            .filter_map(|invocation| invocation.target_component.clone())
155            .collect::<BTreeSet<_>>();
156        components
157            .iter()
158            .filter(|component| {
159                component.element_name.is_some() && !incoming.contains(&component.id)
160            })
161            .collect()
162    } else {
163        routed
164    };
165    if root_components.is_empty() {
166        if let Some(component) = components
167            .iter()
168            .filter(|component| component.element_name.is_some())
169            .min_by_key(|component| component.id.as_str())
170        {
171            root_components.push(component);
172        }
173    }
174
175    root_components
176        .into_iter()
177        .filter_map(|component| {
178            let id = ComponentRootId::for_component(&component.id);
179            let provenance = provenance.get(&component.id)?.clone();
180            Some((
181                id.clone(),
182                ComponentBuildRoot {
183                    id,
184                    component: component.id.clone(),
185                    kind: if component.route_path.is_some() {
186                        ComponentBuildRootKind::Route
187                    } else {
188                        ComponentBuildRootKind::BuildEntry
189                    },
190                    route_path: component.route_path.clone(),
191                    provenance,
192                },
193            ))
194        })
195        .collect()
196}
197
198#[allow(clippy::too_many_arguments)]
199fn expand_component_instances(
200    component: &SemanticId,
201    parent_instance: &ComponentInstanceId,
202    owner_root: &ComponentRootId,
203    depth: usize,
204    component_path: &[SemanticId],
205    components: &[ComponentNode],
206    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
207    template_entities: &[TemplateSemanticEntity],
208    plan: &mut ComponentInstancePlan,
209) {
210    let owned_invocations = invocations
211        .values()
212        .filter(|invocation| invocation.owner_component == *component)
213        .collect::<Vec<_>>();
214
215    for invocation in owned_invocations {
216        let id = ComponentInstanceId::for_invocation(parent_instance, &invocation.id);
217        let structural_region = enclosing_structural_region(invocation, template_entities);
218        let blocked_reason = match invocation.status {
219            ComponentInvocationResolutionStatus::Resolved => invocation
220                .target_component
221                .as_ref()
222                .filter(|target| {
223                    components.iter().any(|component| {
224                        component.id == **target && component.element_name.is_some()
225                    })
226                })
227                .map_or(
228                    Some(BlockedComponentInstanceReason::InvalidTarget),
229                    |target| {
230                        component_path
231                            .contains(target)
232                            .then_some(BlockedComponentInstanceReason::CompositionCycleBoundary)
233                    },
234                ),
235            ComponentInvocationResolutionStatus::UnresolvedSymbol => {
236                Some(BlockedComponentInstanceReason::UnresolvedInvocation)
237            }
238            ComponentInvocationResolutionStatus::UnsupportedDynamicTarget => {
239                Some(BlockedComponentInstanceReason::UnsupportedDynamicInvocation)
240            }
241            ComponentInvocationResolutionStatus::ResolvedNonComponent
242            | ComponentInvocationResolutionStatus::Ambiguous => {
243                Some(BlockedComponentInstanceReason::InvalidTarget)
244            }
245        };
246
247        if let Some(reason) = blocked_reason {
248            plan.blocked.insert(
249                id.clone(),
250                BlockedComponentInstancePlan {
251                    id,
252                    invocation: invocation.id.clone(),
253                    parent_instance: parent_instance.clone(),
254                    owner_root: owner_root.clone(),
255                    target_component: invocation.target_component.clone(),
256                    structural_region,
257                    depth,
258                    reason,
259                    provenance: invocation.provenance.clone(),
260                },
261            );
262            continue;
263        }
264
265        let target = invocation
266            .target_component
267            .as_ref()
268            .expect("resolved executable invocation has a target")
269            .clone();
270        let status = if structural_region.is_some() {
271            ComponentInstanceStatus::StructuralTemplate
272        } else {
273            ComponentInstanceStatus::Planned
274        };
275        plan.instances.insert(
276            id.clone(),
277            ComponentInstance {
278                id: id.clone(),
279                component: target.clone(),
280                invocation: Some(invocation.id.clone()),
281                parent_instance: Some(parent_instance.clone()),
282                owner_root: owner_root.clone(),
283                structural_region,
284                depth,
285                status,
286                provenance: invocation.provenance.clone(),
287            },
288        );
289        let mut next_path = component_path.to_vec();
290        next_path.push(target.clone());
291        expand_component_instances(
292            &target,
293            &id,
294            owner_root,
295            depth + 1,
296            &next_path,
297            components,
298            invocations,
299            template_entities,
300            plan,
301        );
302    }
303}
304
305fn enclosing_structural_region(
306    invocation: &ComponentInvocationEntity,
307    template_entities: &[TemplateSemanticEntity],
308) -> Option<ComponentStructuralRegionId> {
309    let invocation_span = &invocation.provenance.span;
310    template_entities
311        .iter()
312        .filter(|entity| {
313            matches!(
314                entity.kind,
315                TemplateSemanticKind::Conditional | TemplateSemanticKind::List
316            ) && entity.provenance.path == invocation.provenance.path
317                && entity.provenance.span.start <= invocation_span.start
318                && invocation_span.end <= entity.provenance.span.end
319        })
320        .min_by_key(|entity| entity.provenance.span.end - entity.provenance.span.start)
321        .map(|entity| {
322            ComponentStructuralRegionId::for_template_entity(
323                &entity.id,
324                match entity.kind {
325                    TemplateSemanticKind::Conditional => "conditional",
326                    TemplateSemanticKind::List => "keyed-list",
327                    _ => unreachable!("filtered structural template entity"),
328                },
329            )
330        })
331}
332
333#[cfg(test)]
334mod tests {
335    use crate::{
336        build_application_semantic_model, build_application_semantic_model_for_unit,
337        validate_application_semantic_model, BlockedComponentInstanceReason, CompilationUnit,
338        ComponentBuildRootKind, ComponentInstanceStatus, SemanticEntityKind, SemanticOwner,
339    };
340
341    #[test]
342    fn plans_one_root_nested_children_and_distinct_repeated_definition_instances() {
343        let asm = build_application_semantic_model(&presolve_parser::parse_file(
344            "src/App.tsx",
345            r#"
346@component("x-card") class Card extends Component { render() { return <article />; } }
347@component("x-shell") class Shell extends Component { render() { return <section><Card /></section>; } }
348@component("x-page") class Page extends Component { render() { return <main><Shell /><Card /><Card /></main>; } }
349"#,
350        ));
351        let plan = &asm.component_instance_plan;
352
353        assert_eq!(plan.roots.len(), 1);
354        assert_eq!(plan.instances.len(), 5);
355        assert!(plan.blocked.is_empty());
356        let root = plan
357            .instances
358            .values()
359            .find(|instance| instance.depth == 0)
360            .unwrap();
361        assert!(root.parent_instance.is_none());
362        assert!(root.invocation.is_none());
363        let card_instances = plan
364            .instances
365            .values()
366            .filter(|instance| instance.component.as_str().ends_with("component:x-card"))
367            .collect::<Vec<_>>();
368        assert_eq!(card_instances.len(), 3);
369        assert_eq!(
370            card_instances
371                .iter()
372                .map(|instance| instance.id.as_str())
373                .collect::<std::collections::BTreeSet<_>>()
374                .len(),
375            3
376        );
377        assert!(card_instances.iter().any(|instance| instance.depth == 2));
378        assert!(
379            card_instances
380                .iter()
381                .filter(|instance| instance.depth == 1)
382                .count()
383                == 2
384        );
385        assert_eq!(
386            asm.owner(root.id.as_semantic_id()),
387            Some(&SemanticOwner::Application)
388        );
389        assert!(asm
390            .entity(root.id.as_semantic_id())
391            .is_some_and(|entity| entity.kind() == SemanticEntityKind::ComponentInstance));
392        assert!(
393            validate_application_semantic_model(&asm).is_empty(),
394            "canonical instance plans should pass ASM validation"
395        );
396    }
397
398    #[test]
399    fn retains_invalid_target_and_cycle_expansion_boundaries() {
400        let asm = build_application_semantic_model(&presolve_parser::parse_file(
401            "src/Blocked.tsx",
402            r#"
403@component("x-a") class A extends Component { render() { return <B />; } }
404@component("x-b") class B extends Component { render() { return <A />; } }
405type Model = string;
406@component("x-page") class Page extends Component { render() { return <main><A /><Missing /><Model /><Registry.Card /></main>; } }
407"#,
408        ));
409        let blocked = asm
410            .component_instance_plan
411            .blocked
412            .values()
413            .map(|item| item.reason)
414            .collect::<std::collections::BTreeSet<_>>();
415
416        assert!(blocked.contains(&BlockedComponentInstanceReason::CompositionCycleBoundary));
417        assert!(blocked.contains(&BlockedComponentInstanceReason::UnresolvedInvocation));
418        assert!(blocked.contains(&BlockedComponentInstanceReason::InvalidTarget));
419        assert!(blocked.contains(&BlockedComponentInstanceReason::UnsupportedDynamicInvocation));
420        assert!(asm
421            .component_instance_plan
422            .blocked
423            .values()
424            .all(|item| !asm.component_instance_plan.instances.contains_key(&item.id)));
425    }
426
427    #[test]
428    fn selects_one_canonical_build_root_when_only_a_cycle_exists() {
429        let asm = build_application_semantic_model(&presolve_parser::parse_file(
430            "src/Cycle.tsx",
431            r#"
432@component("x-b") class B extends Component { render() { return <A />; } }
433@component("x-a") class A extends Component { render() { return <B />; } }
434"#,
435        ));
436
437        assert_eq!(asm.component_build_roots().len(), 1);
438        assert!(asm.component_build_roots()[0]
439            .component
440            .as_str()
441            .ends_with("component:x-a"));
442        assert!(asm
443            .blocked_component_instances()
444            .iter()
445            .any(|blocked| blocked.reason
446                == BlockedComponentInstanceReason::CompositionCycleBoundary));
447    }
448
449    #[test]
450    fn retains_structural_instance_templates_without_eager_branch_instances() {
451        let asm = build_application_semantic_model(&presolve_parser::parse_file(
452            "src/Structural.tsx",
453            r#"
454@component("x-card") class Card extends Component { render() { return <article />; } }
455@component("x-page") class Page extends Component {
456  shown = state(true);
457  render() { return <main>{this.shown ? <Card /> : <span />}</main>; }
458}
459"#,
460        ));
461        let structural = asm
462            .component_instance_plan
463            .instances
464            .values()
465            .find(|instance| instance.status == ComponentInstanceStatus::StructuralTemplate)
466            .expect("conditional component template");
467
468        assert!(structural.structural_region.is_some());
469        assert_eq!(structural.depth, 1);
470    }
471
472    #[test]
473    fn uses_route_entries_and_keeps_multi_file_instance_ids_deterministic() {
474        let sources = [
475            (
476                "src/Card.tsx",
477                r#"
478@component("x-card")
479export class Card extends Component { render() { return <article />; } }
480"#,
481            ),
482            (
483                "src/Page.tsx",
484                r#"
485import { Card } from "./Card";
486@component("x-page")
487@route("/")
488export class Page extends Component { render() { return <main><Card /><Card /></main>; } }
489"#,
490            ),
491        ];
492        let forward =
493            build_application_semantic_model_for_unit(&CompilationUnit::parse_sources(sources));
494        let reverse = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
495            sources[1], sources[0],
496        ]));
497
498        assert_eq!(
499            forward.component_instance_plan,
500            reverse.component_instance_plan
501        );
502        assert_eq!(forward.component_instance_plan.roots.len(), 1);
503        let root = forward
504            .component_instance_plan
505            .roots
506            .values()
507            .next()
508            .unwrap();
509        assert_eq!(root.kind, ComponentBuildRootKind::Route);
510        assert_eq!(root.route_path.as_deref(), Some("/"));
511        assert!(root.component.as_str().ends_with("component:x-page"));
512        assert_eq!(forward.component_instance_plan.instances.len(), 3);
513    }
514}