presolve_compiler/
component_composition.rs1use 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#[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}