Skip to main content

presolve_compiler/
slot_binding.rs

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/// One compiler-resolved relationship between caller content and callee placement.
23#[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    /// Compiler-issued child instance for automatic file-route layout
33    /// composition. Authored Slot content continues to use `content_fragment`.
34    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/// Join H3 composition facts to exact H4 caller/callee instance relationships.
92#[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/// Joins authored Slot content and compiler-issued file-route layout children
111/// to their exact planned instances. A layout child is not fabricated JSX: it
112/// occupies the layout's declared default Slot directly.
113#[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    // `classify_binding` deliberately treats no authored content as Empty.
228    // The compiler-issued child is the content for a virtual layout edge.
229    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}