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