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#[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#[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}