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            None,
135            &mut plan,
136        );
137    }
138
139    plan
140}
141
142fn collect_build_roots(
143    components: &[ComponentNode],
144    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
145    provenance: &BTreeMap<SemanticId, SourceProvenance>,
146) -> BTreeMap<ComponentRootId, ComponentBuildRoot> {
147    let routed = components
148        .iter()
149        .filter(|component| component.element_name.is_some() && component.route_path.is_some())
150        .collect::<Vec<_>>();
151    let mut root_components = if routed.is_empty() {
152        let incoming = invocations
153            .values()
154            .filter(|invocation| invocation.status == ComponentInvocationResolutionStatus::Resolved)
155            .filter_map(|invocation| invocation.target_component.clone())
156            .collect::<BTreeSet<_>>();
157        components
158            .iter()
159            .filter(|component| {
160                component.element_name.is_some() && !incoming.contains(&component.id)
161            })
162            .collect()
163    } else {
164        routed
165    };
166    if root_components.is_empty() {
167        if let Some(component) = components
168            .iter()
169            .filter(|component| component.element_name.is_some())
170            .min_by_key(|component| component.id.as_str())
171        {
172            root_components.push(component);
173        }
174    }
175
176    root_components
177        .into_iter()
178        .filter_map(|component| {
179            let id = ComponentRootId::for_component(&component.id);
180            let provenance = provenance.get(&component.id)?.clone();
181            Some((
182                id.clone(),
183                ComponentBuildRoot {
184                    id,
185                    component: component.id.clone(),
186                    kind: if component.route_path.is_some() {
187                        ComponentBuildRootKind::Route
188                    } else {
189                        ComponentBuildRootKind::BuildEntry
190                    },
191                    route_path: component.route_path.clone(),
192                    provenance,
193                },
194            ))
195        })
196        .collect()
197}
198
199#[allow(clippy::too_many_arguments)]
200fn expand_component_instances(
201    component: &SemanticId,
202    parent_instance: &ComponentInstanceId,
203    owner_root: &ComponentRootId,
204    depth: usize,
205    component_path: &[SemanticId],
206    components: &[ComponentNode],
207    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
208    template_entities: &[TemplateSemanticEntity],
209    parent_structural_region: Option<&ComponentStructuralRegionId>,
210    plan: &mut ComponentInstancePlan,
211) {
212    let owned_invocations = invocations
213        .values()
214        .filter(|invocation| invocation.owner_component == *component)
215        .collect::<Vec<_>>();
216
217    for invocation in owned_invocations {
218        let id = ComponentInstanceId::for_invocation(parent_instance, &invocation.id);
219        let structural_region = enclosing_structural_region(invocation, template_entities)
220            .or_else(|| parent_structural_region.cloned());
221        let blocked_reason = match invocation.status {
222            ComponentInvocationResolutionStatus::Resolved => invocation
223                .target_component
224                .as_ref()
225                .filter(|target| {
226                    components.iter().any(|component| {
227                        component.id == **target && component.element_name.is_some()
228                    })
229                })
230                .map_or(
231                    Some(BlockedComponentInstanceReason::InvalidTarget),
232                    |target| {
233                        component_path
234                            .contains(target)
235                            .then_some(BlockedComponentInstanceReason::CompositionCycleBoundary)
236                    },
237                ),
238            ComponentInvocationResolutionStatus::UnresolvedSymbol => {
239                Some(BlockedComponentInstanceReason::UnresolvedInvocation)
240            }
241            ComponentInvocationResolutionStatus::UnsupportedDynamicTarget => {
242                Some(BlockedComponentInstanceReason::UnsupportedDynamicInvocation)
243            }
244            ComponentInvocationResolutionStatus::ResolvedNonComponent
245            | ComponentInvocationResolutionStatus::Ambiguous => {
246                Some(BlockedComponentInstanceReason::InvalidTarget)
247            }
248        };
249
250        if let Some(reason) = blocked_reason {
251            plan.blocked.insert(
252                id.clone(),
253                BlockedComponentInstancePlan {
254                    id,
255                    invocation: invocation.id.clone(),
256                    parent_instance: parent_instance.clone(),
257                    owner_root: owner_root.clone(),
258                    target_component: invocation.target_component.clone(),
259                    structural_region: structural_region.clone(),
260                    depth,
261                    reason,
262                    provenance: invocation.provenance.clone(),
263                },
264            );
265            continue;
266        }
267
268        let target = invocation
269            .target_component
270            .as_ref()
271            .expect("resolved executable invocation has a target")
272            .clone();
273        let status = if structural_region.is_some() {
274            ComponentInstanceStatus::StructuralTemplate
275        } else {
276            ComponentInstanceStatus::Planned
277        };
278        plan.instances.insert(
279            id.clone(),
280            ComponentInstance {
281                id: id.clone(),
282                component: target.clone(),
283                invocation: Some(invocation.id.clone()),
284                parent_instance: Some(parent_instance.clone()),
285                owner_root: owner_root.clone(),
286                structural_region: structural_region.clone(),
287                depth,
288                status,
289                provenance: invocation.provenance.clone(),
290            },
291        );
292        let mut next_path = component_path.to_vec();
293        next_path.push(target.clone());
294        expand_component_instances(
295            &target,
296            &id,
297            owner_root,
298            depth + 1,
299            &next_path,
300            components,
301            invocations,
302            template_entities,
303            structural_region.as_ref(),
304            plan,
305        );
306    }
307}
308
309fn enclosing_structural_region(
310    invocation: &ComponentInvocationEntity,
311    template_entities: &[TemplateSemanticEntity],
312) -> Option<ComponentStructuralRegionId> {
313    let invocation_span = &invocation.provenance.span;
314    template_entities
315        .iter()
316        .filter(|entity| {
317            matches!(
318                entity.kind,
319                TemplateSemanticKind::Conditional | TemplateSemanticKind::List
320            ) && entity.provenance.path == invocation.provenance.path
321                && entity.provenance.span.start <= invocation_span.start
322                && invocation_span.end <= entity.provenance.span.end
323        })
324        .min_by_key(|entity| entity.provenance.span.end - entity.provenance.span.start)
325        .map(|entity| {
326            ComponentStructuralRegionId::for_template_entity(
327                &entity.id,
328                match entity.kind {
329                    TemplateSemanticKind::Conditional => "conditional",
330                    TemplateSemanticKind::List => "keyed-list",
331                    _ => unreachable!("filtered structural template entity"),
332                },
333            )
334        })
335}
336
337/// Return the compiler semantic template entity that issued `region`.
338///
339/// Structural-region identity derivation remains centralized here so artifact
340/// producers can join the renderer address without re-creating semantic IDs.
341#[must_use]
342pub fn structural_template_entity_for_region<'a>(
343    region: &ComponentStructuralRegionId,
344    template_entities: &'a [TemplateSemanticEntity],
345) -> Option<&'a TemplateSemanticEntity> {
346    template_entities.iter().find(|entity| {
347        let kind = match entity.kind {
348            TemplateSemanticKind::Conditional => "conditional",
349            TemplateSemanticKind::List => "keyed-list",
350            _ => return false,
351        };
352        ComponentStructuralRegionId::for_template_entity(&entity.id, kind) == *region
353    })
354}
355
356#[cfg(test)]
357mod tests {
358    use crate::{
359        build_application_semantic_model, build_application_semantic_model_for_unit,
360        validate_application_semantic_model, BlockedComponentInstanceReason, CompilationUnit,
361        ComponentBuildRootKind, ComponentInstanceStatus, SemanticEntityKind, SemanticOwner,
362    };
363
364    #[test]
365    fn plans_one_root_nested_children_and_distinct_repeated_definition_instances() {
366        let asm = build_application_semantic_model(&presolve_parser::parse_file(
367            "src/App.tsx",
368            r#"
369@component("x-card") class Card extends Component { render() { return <article />; } }
370@component("x-shell") class Shell extends Component { render() { return <section><Card /></section>; } }
371@component("x-page") class Page extends Component { render() { return <main><Shell /><Card /><Card /></main>; } }
372"#,
373        ));
374        let plan = &asm.component_instance_plan;
375
376        assert_eq!(plan.roots.len(), 1);
377        assert_eq!(plan.instances.len(), 5);
378        assert!(plan.blocked.is_empty());
379        let root = plan
380            .instances
381            .values()
382            .find(|instance| instance.depth == 0)
383            .unwrap();
384        assert!(root.parent_instance.is_none());
385        assert!(root.invocation.is_none());
386        let card_instances = plan
387            .instances
388            .values()
389            .filter(|instance| instance.component.as_str().ends_with("component:x-card"))
390            .collect::<Vec<_>>();
391        assert_eq!(card_instances.len(), 3);
392        assert_eq!(
393            card_instances
394                .iter()
395                .map(|instance| instance.id.as_str())
396                .collect::<std::collections::BTreeSet<_>>()
397                .len(),
398            3
399        );
400        assert!(card_instances.iter().any(|instance| instance.depth == 2));
401        assert!(
402            card_instances
403                .iter()
404                .filter(|instance| instance.depth == 1)
405                .count()
406                == 2
407        );
408        assert_eq!(
409            asm.owner(root.id.as_semantic_id()),
410            Some(&SemanticOwner::Application)
411        );
412        assert!(asm
413            .entity(root.id.as_semantic_id())
414            .is_some_and(|entity| entity.kind() == SemanticEntityKind::ComponentInstance));
415        assert!(
416            validate_application_semantic_model(&asm).is_empty(),
417            "canonical instance plans should pass ASM validation"
418        );
419    }
420
421    #[test]
422    fn retains_invalid_target_and_cycle_expansion_boundaries() {
423        let asm = build_application_semantic_model(&presolve_parser::parse_file(
424            "src/Blocked.tsx",
425            r#"
426@component("x-a") class A extends Component { render() { return <B />; } }
427@component("x-b") class B extends Component { render() { return <A />; } }
428type Model = string;
429@component("x-page") class Page extends Component { render() { return <main><A /><Missing /><Model /><Registry.Card /></main>; } }
430"#,
431        ));
432        let blocked = asm
433            .component_instance_plan
434            .blocked
435            .values()
436            .map(|item| item.reason)
437            .collect::<std::collections::BTreeSet<_>>();
438
439        assert!(blocked.contains(&BlockedComponentInstanceReason::CompositionCycleBoundary));
440        assert!(blocked.contains(&BlockedComponentInstanceReason::UnresolvedInvocation));
441        assert!(blocked.contains(&BlockedComponentInstanceReason::InvalidTarget));
442        assert!(blocked.contains(&BlockedComponentInstanceReason::UnsupportedDynamicInvocation));
443        assert!(asm
444            .component_instance_plan
445            .blocked
446            .values()
447            .all(|item| !asm.component_instance_plan.instances.contains_key(&item.id)));
448    }
449
450    #[test]
451    fn selects_one_canonical_build_root_when_only_a_cycle_exists() {
452        let asm = build_application_semantic_model(&presolve_parser::parse_file(
453            "src/Cycle.tsx",
454            r#"
455@component("x-b") class B extends Component { render() { return <A />; } }
456@component("x-a") class A extends Component { render() { return <B />; } }
457"#,
458        ));
459
460        assert_eq!(asm.component_build_roots().len(), 1);
461        assert!(asm.component_build_roots()[0]
462            .component
463            .as_str()
464            .ends_with("component:x-a"));
465        assert!(asm
466            .blocked_component_instances()
467            .iter()
468            .any(|blocked| blocked.reason
469                == BlockedComponentInstanceReason::CompositionCycleBoundary));
470    }
471
472    #[test]
473    fn retains_structural_instance_templates_without_eager_branch_instances() {
474        let asm = build_application_semantic_model(&presolve_parser::parse_file(
475            "src/Structural.tsx",
476            r#"
477@component("x-leaf") class Leaf extends Component { render() { return <small />; } }
478@component("x-card") class Card extends Component { render() { return <article><Leaf /></article>; } }
479@component("x-page") class Page extends Component {
480  shown = state(true);
481  render() { return <main>{this.shown ? <Card /> : <span />}</main>; }
482}
483"#,
484        ));
485        let structural = asm
486            .component_instance_plan
487            .instances
488            .values()
489            .filter(|instance| instance.status == ComponentInstanceStatus::StructuralTemplate)
490            .collect::<Vec<_>>();
491
492        assert_eq!(structural.len(), 2);
493        assert!(structural
494            .iter()
495            .all(|instance| instance.structural_region.is_some()));
496        assert_eq!(structural[0].depth, 1);
497        assert_eq!(structural[1].depth, 2);
498        assert_eq!(
499            structural[0].structural_region,
500            structural[1].structural_region
501        );
502    }
503
504    #[test]
505    fn uses_route_entries_and_keeps_multi_file_instance_ids_deterministic() {
506        let sources = [
507            (
508                "src/Card.tsx",
509                r#"
510@component("x-card")
511export class Card extends Component { render() { return <article />; } }
512"#,
513            ),
514            (
515                "src/Page.tsx",
516                r#"
517import { Card } from "./Card";
518@component("x-page")
519@route("/")
520export class Page extends Component { render() { return <main><Card /><Card /></main>; } }
521"#,
522            ),
523        ];
524        let forward =
525            build_application_semantic_model_for_unit(&CompilationUnit::parse_sources(sources));
526        let reverse = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
527            sources[1], sources[0],
528        ]));
529
530        assert_eq!(
531            forward.component_instance_plan,
532            reverse.component_instance_plan
533        );
534        assert_eq!(forward.component_instance_plan.roots.len(), 1);
535        let root = forward
536            .component_instance_plan
537            .roots
538            .values()
539            .next()
540            .unwrap();
541        assert_eq!(root.kind, ComponentBuildRootKind::Route);
542        assert_eq!(root.route_path.as_deref(), Some("/"));
543        assert!(root.component.as_str().ends_with("component:x-page"));
544        assert_eq!(forward.component_instance_plan.instances.len(), 3);
545    }
546}