1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4 ComponentInstanceId, ComponentInstancePlan, ComponentInvocationEntity, ComponentInvocationId,
5 SemanticId, SlotBindingId, SlotContentFragment, SlotContentFragmentId,
6 SlotContentFragmentStatus, SlotContentFragmentViolation, SlotEntity, SlotId, SlotKind,
7 SlotOutlet, SlotOutletId, SlotOutletStatus, SlotOutletViolation, SourceProvenance,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum SlotBindingStatus {
12 Bound,
13 Empty,
14 MissingOutlet,
15 UnknownSlot,
16 DuplicateContent,
17 DuplicateOutlet,
18 InvalidOwnership,
19 BlockedInvocation,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SlotBinding {
25 pub id: SlotBindingId,
26 pub caller_instance: ComponentInstanceId,
27 pub callee_instance: ComponentInstanceId,
28 pub invocation: ComponentInvocationId,
29 pub slot: Option<SlotId>,
30 pub outlet: Option<SlotOutletId>,
31 pub content_fragment: Option<SlotContentFragmentId>,
32 pub direct_child_instance: Option<ComponentInstanceId>,
35 pub content_owner_instance: ComponentInstanceId,
36 pub status: SlotBindingStatus,
37 pub provenance: SourceProvenance,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct SlotBindingRegistry {
42 pub bindings: BTreeMap<SlotBindingId, SlotBinding>,
43}
44
45impl SlotBindingRegistry {
46 #[must_use]
47 pub fn binding(&self, id: &SlotBindingId) -> Option<&SlotBinding> {
48 self.bindings.get(id)
49 }
50
51 #[must_use]
52 pub fn for_callee(&self, callee: &ComponentInstanceId) -> Vec<&SlotBinding> {
53 self.bindings
54 .values()
55 .filter(|binding| &binding.callee_instance == callee)
56 .collect()
57 }
58
59 #[must_use]
60 pub fn for_invocation(&self, invocation: &ComponentInvocationId) -> Vec<&SlotBinding> {
61 self.bindings
62 .values()
63 .filter(|binding| &binding.invocation == invocation)
64 .collect()
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
69enum BindingKey {
70 Slot(SlotId),
71 Fragment(SlotContentFragmentId),
72 Invocation,
73}
74
75impl BindingKey {
76 fn identity(&self) -> String {
77 match self {
78 Self::Slot(slot) => format!("slot:{slot}"),
79 Self::Fragment(fragment) => format!("fragment:{fragment}"),
80 Self::Invocation => "invocation".to_string(),
81 }
82 }
83}
84
85struct BindingInputs<'a> {
86 slots: &'a BTreeMap<SlotId, SlotEntity>,
87 fragments: &'a BTreeMap<SlotContentFragmentId, SlotContentFragment>,
88 outlets: &'a BTreeMap<SlotOutletId, SlotOutlet>,
89}
90
91#[must_use]
93pub fn collect_slot_bindings(
94 plan: &ComponentInstancePlan,
95 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
96 slots: &BTreeMap<SlotId, SlotEntity>,
97 fragments: &BTreeMap<SlotContentFragmentId, SlotContentFragment>,
98 outlets: &BTreeMap<SlotOutletId, SlotOutlet>,
99) -> SlotBindingRegistry {
100 collect_slot_bindings_with_virtual_invocations(
101 plan,
102 invocations,
103 &BTreeMap::new(),
104 slots,
105 fragments,
106 outlets,
107 )
108}
109
110#[must_use]
114pub fn collect_slot_bindings_with_virtual_invocations(
115 plan: &ComponentInstancePlan,
116 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
117 virtual_invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
118 slots: &BTreeMap<SlotId, SlotEntity>,
119 fragments: &BTreeMap<SlotContentFragmentId, SlotContentFragment>,
120 outlets: &BTreeMap<SlotOutletId, SlotOutlet>,
121) -> SlotBindingRegistry {
122 let inputs = BindingInputs {
123 slots,
124 fragments,
125 outlets,
126 };
127 let mut registry = SlotBindingRegistry::default();
128
129 for instance in plan.instances.values() {
130 let (Some(invocation_id), Some(caller_instance)) =
131 (&instance.invocation, &instance.parent_instance)
132 else {
133 continue;
134 };
135 let Some(invocation) = invocations
136 .get(invocation_id)
137 .or_else(|| virtual_invocations.get(invocation_id))
138 else {
139 continue;
140 };
141 if invocation.virtual_layout_composition {
142 collect_virtual_layout_binding(
143 &mut registry,
144 &instance.id,
145 caller_instance,
146 invocation,
147 &inputs,
148 );
149 continue;
150 }
151 let caller_component = plan
152 .instances
153 .get(caller_instance)
154 .map(|caller| &caller.component);
155 collect_for_instance(
156 &mut registry,
157 &instance.id,
158 caller_instance,
159 caller_component,
160 Some(&instance.component),
161 invocation,
162 false,
163 &inputs,
164 );
165 }
166
167 for blocked in plan.blocked.values() {
168 let Some(invocation) = invocations
169 .get(&blocked.invocation)
170 .or_else(|| virtual_invocations.get(&blocked.invocation))
171 else {
172 continue;
173 };
174 let caller_component = plan
175 .instances
176 .get(&blocked.parent_instance)
177 .map(|caller| &caller.component);
178 collect_for_instance(
179 &mut registry,
180 &blocked.id,
181 &blocked.parent_instance,
182 caller_component,
183 blocked.target_component.as_ref(),
184 invocation,
185 true,
186 &inputs,
187 );
188 }
189
190 registry
191}
192
193fn collect_virtual_layout_binding(
194 registry: &mut SlotBindingRegistry,
195 child_instance: &ComponentInstanceId,
196 layout_instance: &ComponentInstanceId,
197 invocation: &ComponentInvocationEntity,
198 inputs: &BindingInputs<'_>,
199) {
200 let default_slot = inputs
201 .slots
202 .values()
203 .find(|slot| slot.owner == invocation.owner_component && slot.kind == SlotKind::Default);
204 let (slot, outlet_candidates) = default_slot.map_or_else(
205 || (None, Vec::new()),
206 |slot| {
207 let candidates = inputs
208 .outlets
209 .values()
210 .filter(|outlet| outlet.slot.as_ref() == Some(&slot.id))
211 .collect();
212 (Some(slot.id.clone()), candidates)
213 },
214 );
215 let outlet = (outlet_candidates.len() == 1
216 && outlet_candidates[0].status == SlotOutletStatus::Resolved)
217 .then(|| outlet_candidates[0].id.clone());
218 let status = classify_binding(
219 false,
220 Some(&invocation.owner_component),
221 Some(&invocation.owner_component),
222 None,
223 &outlet_candidates,
224 outlet.as_ref(),
225 slot.as_ref(),
226 );
227 let status = if status == SlotBindingStatus::Empty && outlet.is_some() {
230 SlotBindingStatus::Bound
231 } else {
232 status
233 };
234 let id = SlotBindingId::for_instance(
235 layout_instance,
236 &format!("layout-child:{}", child_instance.as_str()),
237 );
238 registry.bindings.insert(
239 id.clone(),
240 SlotBinding {
241 id,
242 caller_instance: layout_instance.clone(),
243 callee_instance: layout_instance.clone(),
244 invocation: invocation.id.clone(),
245 slot,
246 outlet,
247 content_fragment: None,
248 direct_child_instance: Some(child_instance.clone()),
249 content_owner_instance: child_instance.clone(),
250 status,
251 provenance: invocation.provenance.clone(),
252 },
253 );
254}
255
256#[allow(clippy::similar_names, clippy::too_many_arguments)]
257fn collect_for_instance(
258 registry: &mut SlotBindingRegistry,
259 callee_instance: &ComponentInstanceId,
260 caller_instance: &ComponentInstanceId,
261 caller_component: Option<&SemanticId>,
262 callee_component: Option<&SemanticId>,
263 invocation: &ComponentInvocationEntity,
264 blocked: bool,
265 inputs: &BindingInputs<'_>,
266) {
267 let mut keys = BTreeSet::new();
268 if let Some(callee_component) = callee_component {
269 keys.extend(
270 inputs
271 .slots
272 .values()
273 .filter(|slot| &slot.owner == callee_component)
274 .map(|slot| BindingKey::Slot(slot.id.clone())),
275 );
276 }
277 for fragment in inputs
278 .fragments
279 .values()
280 .filter(|fragment| fragment.invocation == invocation.id)
281 {
282 keys.insert(fragment.slot.as_ref().map_or_else(
283 || BindingKey::Fragment(fragment.id.clone()),
284 |slot| BindingKey::Slot(slot.clone()),
285 ));
286 }
287 if keys.is_empty() && blocked {
288 keys.insert(BindingKey::Invocation);
289 }
290
291 for key in keys {
292 let slot = match &key {
293 BindingKey::Slot(slot) => Some(slot.clone()),
294 BindingKey::Fragment(_) | BindingKey::Invocation => None,
295 };
296 let fragment = fragment_for_key(inputs.fragments, &invocation.id, &key);
297 let outlet_candidates = slot.as_ref().map_or_else(Vec::new, |slot| {
298 inputs
299 .outlets
300 .values()
301 .filter(|outlet| outlet.slot.as_ref() == Some(slot))
302 .collect()
303 });
304 let outlet = (outlet_candidates.len() == 1
305 && outlet_candidates[0].status == SlotOutletStatus::Resolved)
306 .then(|| outlet_candidates[0].id.clone());
307 let status = classify_binding(
308 blocked,
309 caller_component,
310 callee_component,
311 fragment,
312 &outlet_candidates,
313 outlet.as_ref(),
314 slot.as_ref(),
315 );
316 let id = SlotBindingId::for_instance(callee_instance, &key.identity());
317 registry.bindings.insert(
318 id.clone(),
319 SlotBinding {
320 id,
321 caller_instance: caller_instance.clone(),
322 callee_instance: callee_instance.clone(),
323 invocation: invocation.id.clone(),
324 slot,
325 outlet,
326 content_fragment: fragment.map(|fragment| fragment.id.clone()),
327 direct_child_instance: None,
328 content_owner_instance: caller_instance.clone(),
329 status,
330 provenance: invocation.provenance.clone(),
331 },
332 );
333 }
334}
335
336fn fragment_for_key<'a>(
337 fragments: &'a BTreeMap<SlotContentFragmentId, SlotContentFragment>,
338 invocation: &ComponentInvocationId,
339 key: &BindingKey,
340) -> Option<&'a SlotContentFragment> {
341 match key {
342 BindingKey::Slot(slot) => fragments.values().find(|fragment| {
343 &fragment.invocation == invocation && fragment.slot.as_ref() == Some(slot)
344 }),
345 BindingKey::Fragment(fragment) => fragments.get(fragment),
346 BindingKey::Invocation => None,
347 }
348}
349
350#[allow(clippy::similar_names, clippy::too_many_arguments)]
351fn classify_binding(
352 blocked: bool,
353 caller_component: Option<&SemanticId>,
354 callee_component: Option<&SemanticId>,
355 fragment: Option<&SlotContentFragment>,
356 outlet_candidates: &[&SlotOutlet],
357 outlet: Option<&SlotOutletId>,
358 slot: Option<&SlotId>,
359) -> SlotBindingStatus {
360 if blocked {
361 return SlotBindingStatus::BlockedInvocation;
362 }
363 let invalid_fragment_owner = fragment.is_some_and(|fragment| {
364 caller_component.is_none_or(|component| &fragment.owner_component != component)
365 });
366 let invalid_outlet_owner = outlet_candidates.iter().any(|outlet| {
367 callee_component.is_none_or(|component| &outlet.owner_component != component)
368 });
369 if invalid_fragment_owner || invalid_outlet_owner {
370 return SlotBindingStatus::InvalidOwnership;
371 }
372 if fragment.is_some_and(|fragment| {
373 fragment
374 .violations
375 .contains(&SlotContentFragmentViolation::DuplicateFragment)
376 }) {
377 return SlotBindingStatus::DuplicateContent;
378 }
379 if slot.is_none()
380 || fragment.is_some_and(|fragment| {
381 fragment.status == SlotContentFragmentStatus::Blocked
382 && !fragment
383 .violations
384 .contains(&SlotContentFragmentViolation::DuplicateFragment)
385 })
386 {
387 return SlotBindingStatus::UnknownSlot;
388 }
389 if outlet_candidates.len() > 1
390 || outlet_candidates.iter().any(|outlet| {
391 outlet
392 .violations
393 .contains(&SlotOutletViolation::DuplicateOutlet)
394 })
395 {
396 return SlotBindingStatus::DuplicateOutlet;
397 }
398 match (fragment, outlet) {
399 (Some(_), Some(_)) => SlotBindingStatus::Bound,
400 (Some(_), None) => SlotBindingStatus::MissingOutlet,
401 (None, _) => SlotBindingStatus::Empty,
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use std::collections::BTreeSet;
408
409 use crate::{
410 build_application_semantic_model, collect_slot_bindings,
411 validate_application_semantic_model, SemanticOwner, SlotBindingStatus,
412 };
413
414 #[test]
415 fn binds_repeated_nested_callees_and_preserves_caller_content_ownership() {
416 let asm = build_application_semantic_model(&presolve_parser::parse_file(
417 "src/Bindings.tsx",
418 r#"
419@component("x-card") class Card extends Component {
420 @slot() children!: SlotContent;
421 @slot() header!: SlotContent;
422 render() { return <article><slot name="header" /><slot /></article>; }
423}
424@component("x-shell") class Shell extends Component {
425 title = state("Title");
426 render() { return <Card><template slot="header"><h1>{this.title}</h1></template><p>Body</p></Card>; }
427}
428@component("x-page") class Page extends Component {
429 render() { return <main><Shell /><Shell /></main>; }
430}
431"#,
432 ));
433 let bound = asm
434 .slot_bindings
435 .bindings
436 .values()
437 .filter(|binding| binding.status == SlotBindingStatus::Bound)
438 .collect::<Vec<_>>();
439
440 assert_eq!(bound.len(), 4);
441 assert_eq!(
442 bound
443 .iter()
444 .map(|binding| &binding.callee_instance)
445 .collect::<BTreeSet<_>>()
446 .len(),
447 2
448 );
449 assert_eq!(
450 bound
451 .iter()
452 .map(|binding| &binding.caller_instance)
453 .collect::<BTreeSet<_>>()
454 .len(),
455 2
456 );
457 assert!(bound.iter().all(|binding| {
458 binding.content_owner_instance == binding.caller_instance
459 && binding.outlet.is_some()
460 && binding.content_fragment.is_some()
461 }));
462 for binding in bound {
463 let fragment = asm
464 .slot_content_fragment(binding.content_fragment.as_ref().unwrap())
465 .unwrap();
466 assert!(fragment.content_template_entities.iter().all(|entity| {
467 asm.owner(entity).is_some_and(|owner| {
468 matches!(owner, SemanticOwner::Entity(template) if template.as_str().contains("component:x-shell/template:render"))
469 })
470 }));
471 }
472 assert!(validate_application_semantic_model(&asm).is_empty());
473 }
474
475 #[test]
476 fn retains_empty_unknown_missing_and_duplicate_binding_states() {
477 let asm = build_application_semantic_model(&presolve_parser::parse_file(
478 "src/BindingStates.tsx",
479 r#"
480@component("x-empty") class Empty extends Component {
481 @slot() children!: SlotContent;
482 render() { return <div><slot /></div>; }
483}
484@component("x-missing") class Missing extends Component {
485 @slot() header!: SlotContent;
486 render() { return <div />; }
487}
488@component("x-unknown") class Unknown extends Component {
489 @slot() children!: SlotContent;
490 render() { return <div><slot /></div>; }
491}
492@component("x-dup-content") class DupContent extends Component {
493 @slot() header!: SlotContent;
494 render() { return <div><slot name="header" /></div>; }
495}
496@component("x-dup-outlet") class DupOutlet extends Component {
497 @slot() children!: SlotContent;
498 render() { return <div><slot /><slot /></div>; }
499}
500@component("x-page") class Page extends Component {
501 render() { return <main><Empty /><Missing><template slot="header"><h1 /></template></Missing><Unknown><template slot="missing"><p /></template></Unknown><DupContent><template slot="header"><h1 /></template><template slot="header"><h2 /></template></DupContent><DupOutlet><p /></DupOutlet></main>; }
502}
503"#,
504 ));
505 let statuses = asm
506 .slot_bindings
507 .bindings
508 .values()
509 .map(|binding| binding.status)
510 .collect::<BTreeSet<_>>();
511
512 assert!(statuses.contains(&SlotBindingStatus::Empty));
513 assert!(statuses.contains(&SlotBindingStatus::MissingOutlet));
514 assert!(statuses.contains(&SlotBindingStatus::UnknownSlot));
515 assert!(statuses.contains(&SlotBindingStatus::DuplicateContent));
516 assert!(statuses.contains(&SlotBindingStatus::DuplicateOutlet));
517 }
518
519 #[test]
520 fn retains_blocked_invocations_and_validates_canonical_ownership() {
521 let mut asm = build_application_semantic_model(&presolve_parser::parse_file(
522 "src/BlockedBinding.tsx",
523 r#"
524@component("x-card") class Card extends Component {
525 @slot() children!: SlotContent;
526 render() { return <slot />; }
527}
528@component("x-page") class Page extends Component {
529 render() { return <main><Missing><p /></Missing><Card><p /></Card></main>; }
530}
531"#,
532 ));
533 assert!(asm
534 .slot_bindings
535 .bindings
536 .values()
537 .any(|binding| binding.status == SlotBindingStatus::BlockedInvocation));
538
539 let fragment = asm
540 .slot_content_fragments
541 .values_mut()
542 .find(|fragment| fragment.slot.is_some())
543 .unwrap();
544 fragment.owner_component = crate::SemanticId::component(Some("x-wrong"), "Wrong");
545 asm.slot_bindings = collect_slot_bindings(
546 &asm.component_instance_plan,
547 &asm.component_invocations,
548 &asm.slots,
549 &asm.slot_content_fragments,
550 &asm.slot_outlets,
551 );
552 assert!(asm
553 .slot_bindings
554 .bindings
555 .values()
556 .any(|binding| binding.status == SlotBindingStatus::InvalidOwnership));
557 asm.slot_bindings
558 .bindings
559 .values_mut()
560 .find(|binding| binding.status == SlotBindingStatus::InvalidOwnership)
561 .unwrap()
562 .status = SlotBindingStatus::Bound;
563 assert!(validate_application_semantic_model(&asm)
564 .iter()
565 .any(|diagnostic| diagnostic.code == "PSASM1195"));
566 }
567}