Skip to main content

presolve_compiler/
component_composition.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4    BlockedComponentInstanceReason, ComponentInstanceId, ComponentInstancePlan,
5    ComponentInvocationEntity, ComponentInvocationId, ComponentInvocationResolutionStatus,
6    SemanticId,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ComponentCompositionCycle {
11    pub components: Vec<SemanticId>,
12    pub invocations: Vec<ComponentInvocationId>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub struct ComponentCompositionAnalysis {
17    pub adjacency: BTreeMap<SemanticId, Vec<SemanticId>>,
18    pub cycles: Vec<ComponentCompositionCycle>,
19    pub blocked_cycle_by_instance: BTreeMap<ComponentInstanceId, usize>,
20}
21
22/// Analyze only canonical resolved definition-to-definition invocation edges.
23#[must_use]
24pub fn analyze_component_composition(
25    components: &BTreeSet<SemanticId>,
26    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
27    plan: &ComponentInstancePlan,
28) -> ComponentCompositionAnalysis {
29    let adjacency_sets = components
30        .iter()
31        .map(|component| {
32            let targets = invocations
33                .values()
34                .filter(|invocation| {
35                    invocation.status == ComponentInvocationResolutionStatus::Resolved
36                        && invocation.owner_component == *component
37                })
38                .filter_map(|invocation| invocation.target_component.clone())
39                .filter(|target| components.contains(target))
40                .collect::<BTreeSet<_>>();
41            (component.clone(), targets)
42        })
43        .collect::<BTreeMap<_, _>>();
44    let reverse = reverse_adjacency(&adjacency_sets);
45    let mut visited = BTreeSet::new();
46    let mut finish = Vec::new();
47    for component in components {
48        visit(component, &adjacency_sets, &mut visited, &mut finish);
49    }
50    visited.clear();
51    let mut cycles = Vec::new();
52    for component in finish.into_iter().rev() {
53        if !visited.insert(component.clone()) {
54            continue;
55        }
56        let mut members = BTreeSet::new();
57        collect_component(&component, &reverse, &mut visited, &mut members);
58        let self_cycle = members.len() == 1
59            && adjacency_sets
60                .get(&component)
61                .is_some_and(|targets| targets.contains(&component));
62        if members.len() > 1 || self_cycle {
63            let cycle_invocations = invocations
64                .values()
65                .filter(|invocation| {
66                    invocation.status == ComponentInvocationResolutionStatus::Resolved
67                        && members.contains(&invocation.owner_component)
68                        && invocation
69                            .target_component
70                            .as_ref()
71                            .is_some_and(|target| members.contains(target))
72                })
73                .map(|invocation| invocation.id.clone())
74                .collect();
75            cycles.push(ComponentCompositionCycle {
76                components: members.into_iter().collect(),
77                invocations: cycle_invocations,
78            });
79        }
80    }
81    cycles.sort_by(|left, right| {
82        (&left.components, &left.invocations).cmp(&(&right.components, &right.invocations))
83    });
84    let blocked_cycle_by_instance = plan
85        .blocked
86        .values()
87        .filter(|blocked| {
88            blocked.reason == BlockedComponentInstanceReason::CompositionCycleBoundary
89        })
90        .filter_map(|blocked| {
91            let invocation = invocations.get(&blocked.invocation)?;
92            let target = invocation.target_component.as_ref()?;
93            cycles
94                .iter()
95                .position(|cycle| {
96                    cycle.components.contains(&invocation.owner_component)
97                        && cycle.components.contains(target)
98                        && cycle.invocations.contains(&invocation.id)
99                })
100                .map(|cycle| (blocked.id.clone(), cycle))
101        })
102        .collect();
103    let adjacency = adjacency_sets
104        .into_iter()
105        .map(|(source, targets)| (source, targets.into_iter().collect()))
106        .collect();
107
108    ComponentCompositionAnalysis {
109        adjacency,
110        cycles,
111        blocked_cycle_by_instance,
112    }
113}
114
115fn reverse_adjacency(
116    adjacency: &BTreeMap<SemanticId, BTreeSet<SemanticId>>,
117) -> BTreeMap<SemanticId, BTreeSet<SemanticId>> {
118    let mut reverse = adjacency
119        .keys()
120        .cloned()
121        .map(|component| (component, BTreeSet::new()))
122        .collect::<BTreeMap<_, _>>();
123    for (source, targets) in adjacency {
124        for target in targets {
125            if let Some(sources) = reverse.get_mut(target) {
126                sources.insert(source.clone());
127            }
128        }
129    }
130    reverse
131}
132
133fn visit(
134    component: &SemanticId,
135    adjacency: &BTreeMap<SemanticId, BTreeSet<SemanticId>>,
136    visited: &mut BTreeSet<SemanticId>,
137    finish: &mut Vec<SemanticId>,
138) {
139    if !visited.insert(component.clone()) {
140        return;
141    }
142    if let Some(targets) = adjacency.get(component) {
143        for target in targets {
144            visit(target, adjacency, visited, finish);
145        }
146    }
147    finish.push(component.clone());
148}
149
150fn collect_component(
151    component: &SemanticId,
152    reverse: &BTreeMap<SemanticId, BTreeSet<SemanticId>>,
153    visited: &mut BTreeSet<SemanticId>,
154    members: &mut BTreeSet<SemanticId>,
155) {
156    members.insert(component.clone());
157    if let Some(sources) = reverse.get(component) {
158        for source in sources {
159            if visited.insert(source.clone()) {
160                collect_component(source, reverse, visited, members);
161            }
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use crate::{build_application_semantic_model, validate_application_semantic_model};
169
170    #[test]
171    fn detects_self_two_node_and_long_cycles_with_exact_boundaries() {
172        let asm = build_application_semantic_model(&presolve_parser::parse_file(
173            "src/Cycles.tsx",
174            r#"
175@component("x-self") class SelfCycle extends Component { render() { return <SelfCycle />; } }
176@component("x-a") class A extends Component { render() { return <B />; } }
177@component("x-b") class B extends Component { render() { return <A />; } }
178@component("x-c") class C extends Component { render() { return <D />; } }
179@component("x-d") class D extends Component { render() { return <E />; } }
180@component("x-e") class E extends Component { render() { return <C />; } }
181@component("x-page") class Page extends Component { render() { return <main><SelfCycle /><A /><C /></main>; } }
182"#,
183        ));
184        let analysis = &asm.component_composition;
185        assert_eq!(analysis.cycles.len(), 3);
186        assert_eq!(
187            analysis
188                .cycles
189                .iter()
190                .map(|cycle| cycle.components.len())
191                .collect::<Vec<_>>(),
192            vec![2, 3, 1]
193        );
194        assert_eq!(analysis.blocked_cycle_by_instance.len(), 3);
195        assert!(asm.component_instance_plan.instances.len() < 10);
196        assert!(validate_application_semantic_model(&asm).is_empty());
197    }
198
199    #[test]
200    fn excludes_slot_ownership_and_unresolved_invocations_from_cycle_edges() {
201        let asm = build_application_semantic_model(&presolve_parser::parse_file(
202            "src/Acyclic.tsx",
203            r#"
204@component("x-card") class Card extends Component {
205  @slot() children!: SlotContent;
206  render() { return <article><slot /></article>; }
207}
208@component("x-page") class Page extends Component {
209  render() { return <Card><Missing /></Card>; }
210}
211"#,
212        ));
213        assert!(asm.component_composition.cycles.is_empty());
214        assert!(asm
215            .component_composition
216            .blocked_cycle_by_instance
217            .is_empty());
218    }
219
220    #[test]
221    fn validation_rejects_mutated_cycle_analysis() {
222        let mut asm = build_application_semantic_model(&presolve_parser::parse_file(
223            "src/ValidateCycle.tsx",
224            r#"
225@component("x-a") class A extends Component { render() { return <A />; } }
226"#,
227        ));
228        asm.component_composition.cycles.clear();
229        assert!(validate_application_semantic_model(&asm)
230            .iter()
231            .any(|diagnostic| diagnostic.code == "PSASM1197"));
232    }
233}