Skip to main content

presolve_compiler/
composition_typing.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4    CompatibilityStatus, ComponentInvocationEntity, ComponentInvocationId,
5    ComponentInvocationResolutionStatus, ConsumerInstanceId, ContextBindingTypeRecord,
6    ContextDefaultSourceInstanceId, ContextId, ContextLifetimeAnalysis,
7    ContextSerializationCompatibility, ContextTypeRecord, ExecutionBoundary,
8    InstanceContextRegistry, InstanceContextResolutionStatus, ProviderId, ProviderTypeRecord,
9    SemanticId, SemanticOwner, SemanticReference, SemanticType, SlotBinding, SlotBindingId,
10    SlotBindingRegistry, SlotBindingStatus, SlotContentFragment, SlotContentFragmentId, SlotEntity,
11    SlotId, SlotOutlet, SlotOutletId, SourceProvenance,
12};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum CompositionCompatibility {
16    Compatible,
17    Incompatible,
18    Unknown,
19    Unresolved,
20    Blocked,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ComponentInvocationTypeRecord {
25    pub invocation: ComponentInvocationId,
26    pub caller_boundary: ExecutionBoundary,
27    pub target_boundary: Option<ExecutionBoundary>,
28    pub boundary_compatibility: CompositionCompatibility,
29    pub overall: CompositionCompatibility,
30    pub provenance: SourceProvenance,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SlotBindingTypeRecord {
35    pub binding: SlotBindingId,
36    pub slot: Option<SlotId>,
37    pub slot_type: Option<SemanticType>,
38    pub content_type: Option<SemanticType>,
39    pub type_compatibility: CompositionCompatibility,
40    pub caller_dependencies_valid: bool,
41    pub outlet_ownership_valid: bool,
42    pub cardinality_valid: bool,
43    pub overall: CompositionCompatibility,
44    pub provenance: SourceProvenance,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct InstanceContextBindingTypeRecord {
49    pub consumer_instance: ConsumerInstanceId,
50    pub context: Option<ContextId>,
51    pub provider: Option<ProviderId>,
52    pub default_source: Option<ContextDefaultSourceInstanceId>,
53    pub type_compatibility: CompositionCompatibility,
54    pub lifetime_compatibility: CompositionCompatibility,
55    pub serialization_compatibility: CompositionCompatibility,
56    pub boundary_compatibility: CompositionCompatibility,
57    pub overall: CompositionCompatibility,
58    pub provenance: SourceProvenance,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
62pub struct CompositionTypeProducts {
63    pub invocations: BTreeMap<ComponentInvocationId, ComponentInvocationTypeRecord>,
64    pub slot_bindings: BTreeMap<SlotBindingId, SlotBindingTypeRecord>,
65    pub instance_context_bindings: BTreeMap<ConsumerInstanceId, InstanceContextBindingTypeRecord>,
66}
67
68#[allow(clippy::too_many_arguments)]
69#[must_use]
70pub fn collect_composition_type_products(
71    components: &BTreeSet<SemanticId>,
72    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
73    slot_bindings: &SlotBindingRegistry,
74    slots: &BTreeMap<SlotId, SlotEntity>,
75    fragments: &BTreeMap<SlotContentFragmentId, SlotContentFragment>,
76    outlets: &BTreeMap<SlotOutletId, SlotOutlet>,
77    instance_context: &InstanceContextRegistry,
78    context_binding_types: &BTreeMap<crate::ConsumerId, ContextBindingTypeRecord>,
79    context_types: &BTreeMap<ContextId, ContextTypeRecord>,
80    provider_types: &BTreeMap<ProviderId, ProviderTypeRecord>,
81    _context_lifetime: &ContextLifetimeAnalysis,
82    ownership: &BTreeMap<SemanticId, SemanticOwner>,
83    references: &[SemanticReference],
84) -> CompositionTypeProducts {
85    let invocation_records = invocations
86        .values()
87        .map(|invocation| {
88            let resolved = invocation.status == ComponentInvocationResolutionStatus::Resolved
89                && invocation
90                    .target_component
91                    .as_ref()
92                    .is_some_and(|target| components.contains(target));
93            let overall = if resolved {
94                CompositionCompatibility::Compatible
95            } else {
96                CompositionCompatibility::Unresolved
97            };
98            (
99                invocation.id.clone(),
100                ComponentInvocationTypeRecord {
101                    invocation: invocation.id.clone(),
102                    caller_boundary: ExecutionBoundary::Client,
103                    target_boundary: resolved.then_some(ExecutionBoundary::Client),
104                    boundary_compatibility: overall,
105                    overall,
106                    provenance: invocation.provenance.clone(),
107                },
108            )
109        })
110        .collect();
111    let slot_records = slot_bindings
112        .bindings
113        .values()
114        .map(|binding| {
115            let record = collect_slot_binding_type(
116                binding,
117                invocations
118                    .get(&binding.invocation)
119                    .map(|invocation| &invocation.owner_component),
120                slots,
121                fragments,
122                outlets,
123                components,
124                ownership,
125                references,
126            );
127            (binding.id.clone(), record)
128        })
129        .collect();
130    let context_records = instance_context
131        .resolutions
132        .values()
133        .map(|resolution| {
134            let declaration = context_binding_types.get(&resolution.consumer_instance.consumer);
135            let provider = resolution
136                .provider_instance
137                .as_ref()
138                .map(|provider| provider.provider.clone());
139            let record = collect_instance_context_type(
140                resolution,
141                declaration,
142                provider
143                    .as_ref()
144                    .and_then(|provider| provider_types.get(provider)),
145                resolution
146                    .context
147                    .as_ref()
148                    .and_then(|context| context_types.get(context)),
149            );
150            (resolution.consumer_instance.clone(), record)
151        })
152        .collect();
153
154    CompositionTypeProducts {
155        invocations: invocation_records,
156        slot_bindings: slot_records,
157        instance_context_bindings: context_records,
158    }
159}
160
161#[allow(clippy::too_many_arguments)]
162fn collect_slot_binding_type(
163    binding: &SlotBinding,
164    caller_component: Option<&SemanticId>,
165    slots: &BTreeMap<SlotId, SlotEntity>,
166    fragments: &BTreeMap<SlotContentFragmentId, SlotContentFragment>,
167    outlets: &BTreeMap<SlotOutletId, SlotOutlet>,
168    components: &BTreeSet<SemanticId>,
169    ownership: &BTreeMap<SemanticId, SemanticOwner>,
170    references: &[SemanticReference],
171) -> SlotBindingTypeRecord {
172    let slot = binding.slot.as_ref().and_then(|slot| slots.get(slot));
173    let fragment = binding
174        .content_fragment
175        .as_ref()
176        .and_then(|fragment| fragments.get(fragment));
177    let slot_type = slot.map(|slot| slot.semantic_type.clone());
178    let content_type = fragment.map(|_| SemanticType::SlotContent);
179    let type_compatibility = match (&slot_type, &content_type) {
180        (Some(SemanticType::SlotContent), Some(SemanticType::SlotContent) | None) => {
181            CompositionCompatibility::Compatible
182        }
183        (None, _) => CompositionCompatibility::Unknown,
184        _ => CompositionCompatibility::Incompatible,
185    };
186    let caller_dependencies_valid = fragment.is_none_or(|fragment| {
187        fragment.content_template_entities.iter().all(|entity| {
188            component_owner(entity, components, ownership).as_ref() == caller_component
189                && references
190                    .iter()
191                    .filter(|reference| reference.source == *entity)
192                    .all(|reference| {
193                        component_owner(&reference.target, components, ownership).as_ref()
194                            == caller_component
195                    })
196        })
197    });
198    let outlet_ownership_valid = binding.outlet.as_ref().is_none_or(|outlet| {
199        outlets.get(outlet).is_some_and(|outlet| {
200            binding.slot.as_ref() == outlet.slot.as_ref()
201                && slots
202                    .get(binding.slot.as_ref().expect("bound outlets name a Slot"))
203                    .is_some_and(|slot| slot.owner == outlet.owner_component)
204        })
205    });
206    let cardinality_valid = matches!(
207        binding.status,
208        SlotBindingStatus::Bound | SlotBindingStatus::Empty
209    );
210    let overall = if matches!(binding.status, SlotBindingStatus::BlockedInvocation) {
211        CompositionCompatibility::Blocked
212    } else if type_compatibility == CompositionCompatibility::Compatible
213        && caller_dependencies_valid
214        && outlet_ownership_valid
215        && cardinality_valid
216    {
217        CompositionCompatibility::Compatible
218    } else {
219        CompositionCompatibility::Incompatible
220    };
221    SlotBindingTypeRecord {
222        binding: binding.id.clone(),
223        slot: binding.slot.clone(),
224        slot_type,
225        content_type,
226        type_compatibility,
227        caller_dependencies_valid,
228        outlet_ownership_valid,
229        cardinality_valid,
230        overall,
231        provenance: binding.provenance.clone(),
232    }
233}
234
235fn component_owner(
236    entity: &SemanticId,
237    components: &BTreeSet<SemanticId>,
238    ownership: &BTreeMap<SemanticId, SemanticOwner>,
239) -> Option<SemanticId> {
240    let mut current = entity;
241    let mut seen = BTreeSet::new();
242    loop {
243        if components.contains(current) {
244            return Some(current.clone());
245        }
246        if !seen.insert(current.clone()) {
247            return None;
248        }
249        let SemanticOwner::Entity(owner) = ownership.get(current)? else {
250            return None;
251        };
252        current = owner;
253    }
254}
255
256fn collect_instance_context_type(
257    resolution: &crate::InstanceContextResolution,
258    declaration: Option<&ContextBindingTypeRecord>,
259    provider: Option<&ProviderTypeRecord>,
260    context: Option<&ContextTypeRecord>,
261) -> InstanceContextBindingTypeRecord {
262    let type_compatibility = match resolution.status {
263        InstanceContextResolutionStatus::ProviderSelected => {
264            provider.map_or(CompositionCompatibility::Unknown, |provider| {
265                combine_compatibility([
266                    provider.value_to_declaration,
267                    provider.declaration_to_context,
268                    declaration.map_or(CompatibilityStatus::Unknown, |record| {
269                        record.context_to_consumer
270                    }),
271                ])
272            })
273        }
274        InstanceContextResolutionStatus::ContextDefaultSelected => combine_compatibility([
275            context
276                .and_then(|record| record.default_compatibility)
277                .unwrap_or(CompatibilityStatus::Unknown),
278            declaration.map_or(CompatibilityStatus::Unknown, |record| {
279                record.context_to_consumer
280            }),
281        ]),
282        InstanceContextResolutionStatus::Unresolved
283        | InstanceContextResolutionStatus::Ambiguous
284        | InstanceContextResolutionStatus::InvalidContextReference => {
285            CompositionCompatibility::Unresolved
286        }
287    };
288    // H6 has already selected a source through the canonical instance ancestry
289    // graph. A selected ancestor Provider or root-qualified default therefore
290    // outlives this Consumer instance even when the retained Phase G
291    // declaration-only graph could not express that composed relationship.
292    let lifetime_compatibility = match resolution.status {
293        InstanceContextResolutionStatus::ProviderSelected
294        | InstanceContextResolutionStatus::ContextDefaultSelected => {
295            CompositionCompatibility::Compatible
296        }
297        _ => CompositionCompatibility::Unresolved,
298    };
299    let serialization = provider.map_or_else(
300        || context.map(|record| record.serialization),
301        |record| Some(record.serialization),
302    );
303    let serialization_compatibility = serialization.map_or(
304        CompositionCompatibility::Unknown,
305        |serialization| match serialization {
306            ContextSerializationCompatibility::Serializable => CompositionCompatibility::Compatible,
307            ContextSerializationCompatibility::NonSerializable => {
308                CompositionCompatibility::Incompatible
309            }
310            ContextSerializationCompatibility::Unknown => CompositionCompatibility::Unknown,
311        },
312    );
313    let boundary = provider.map_or_else(
314        || context.map(|record| record.boundary_compatibility),
315        |record| Some(record.boundary_compatibility),
316    );
317    let boundary_compatibility =
318        boundary.map_or(CompositionCompatibility::Unknown, map_compatibility);
319    let overall = combine_composition([
320        type_compatibility,
321        lifetime_compatibility,
322        serialization_compatibility,
323        boundary_compatibility,
324    ]);
325    InstanceContextBindingTypeRecord {
326        consumer_instance: resolution.consumer_instance.clone(),
327        context: resolution.context.clone(),
328        provider: resolution
329            .provider_instance
330            .as_ref()
331            .map(|provider| provider.provider.clone()),
332        default_source: resolution.default_source.clone(),
333        type_compatibility,
334        lifetime_compatibility,
335        serialization_compatibility,
336        boundary_compatibility,
337        overall,
338        provenance: resolution.provenance.clone(),
339    }
340}
341
342fn combine_compatibility<const N: usize>(
343    statuses: [CompatibilityStatus; N],
344) -> CompositionCompatibility {
345    combine_composition(statuses.map(map_compatibility))
346}
347
348fn map_compatibility(status: CompatibilityStatus) -> CompositionCompatibility {
349    match status {
350        CompatibilityStatus::Compatible => CompositionCompatibility::Compatible,
351        CompatibilityStatus::Incompatible => CompositionCompatibility::Incompatible,
352        CompatibilityStatus::Unknown => CompositionCompatibility::Unknown,
353    }
354}
355
356fn combine_composition<const N: usize>(
357    statuses: [CompositionCompatibility; N],
358) -> CompositionCompatibility {
359    if statuses.contains(&CompositionCompatibility::Incompatible) {
360        CompositionCompatibility::Incompatible
361    } else if statuses.contains(&CompositionCompatibility::Unresolved) {
362        CompositionCompatibility::Unresolved
363    } else if statuses.contains(&CompositionCompatibility::Blocked) {
364        CompositionCompatibility::Blocked
365    } else if statuses.contains(&CompositionCompatibility::Unknown) {
366        CompositionCompatibility::Unknown
367    } else {
368        CompositionCompatibility::Compatible
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use crate::{
375        build_application_semantic_model, validate_application_semantic_model,
376        CompositionCompatibility, ExecutionBoundary,
377    };
378
379    #[test]
380    fn types_valid_invocations_slots_and_instance_context_without_reselection() {
381        let asm = build_application_semantic_model(&presolve_parser::parse_file(
382            "src/CompositionTypes.tsx",
383            r#"
384@component("x-theme") class Theme extends Component {
385  @context() color!: string;
386  render() { return <div />; }
387}
388@component("x-leaf") class Leaf extends Component {
389  @consume(Theme.color) color!: string;
390  render() { return <span />; }
391}
392@component("x-card") class Card extends Component {
393  @slot() children!: SlotContent;
394  @provide(Theme.color) color: string = "blue";
395  render() { return <article><Leaf /><slot /></article>; }
396}
397@component("x-page") class Page extends Component {
398  render() { return <Card><p>caller</p></Card>; }
399}
400"#,
401        ));
402        assert!(asm.composition_types.invocations.values().all(|record| {
403            record.caller_boundary == ExecutionBoundary::Client
404                && record.target_boundary == Some(ExecutionBoundary::Client)
405                && record.overall == CompositionCompatibility::Compatible
406        }));
407        assert!(asm.composition_types.slot_bindings.values().all(|record| {
408            record.overall == CompositionCompatibility::Compatible
409                && record.caller_dependencies_valid
410                && record.outlet_ownership_valid
411                && record.cardinality_valid
412        }));
413        let context = asm
414            .composition_types
415            .instance_context_bindings
416            .values()
417            .next()
418            .unwrap();
419        assert_eq!(context.overall, CompositionCompatibility::Compatible);
420        assert_eq!(
421            context.provider,
422            asm.instance_context
423                .resolutions
424                .values()
425                .next()
426                .unwrap()
427                .provider_instance
428                .as_ref()
429                .map(|provider| provider.provider.clone())
430        );
431        assert!(validate_application_semantic_model(&asm).is_empty());
432    }
433
434    #[test]
435    fn retains_unresolved_blocked_and_incompatible_composition_facts() {
436        let asm = build_application_semantic_model(&presolve_parser::parse_file(
437            "src/InvalidCompositionTypes.tsx",
438            r#"
439@component("x-theme") class Theme extends Component {
440  @context() color!: string;
441  render() { return <div />; }
442}
443@component("x-leaf") class Leaf extends Component {
444  @consume(Theme.color) color!: string;
445  render() { return <span />; }
446}
447@component("x-page") class Page extends Component {
448  @provide(Theme.color) color: number = 1;
449  render() { return <main><Leaf /><Missing><p /></Missing></main>; }
450}
451"#,
452        ));
453        assert!(asm
454            .composition_types
455            .invocations
456            .values()
457            .any(|record| record.overall == CompositionCompatibility::Unresolved));
458        assert!(asm
459            .composition_types
460            .slot_bindings
461            .values()
462            .any(|record| record.overall == CompositionCompatibility::Blocked));
463        let context = asm
464            .composition_types
465            .instance_context_bindings
466            .values()
467            .next()
468            .unwrap();
469        assert_eq!(context.overall, CompositionCompatibility::Incompatible);
470        assert!(context.provider.is_some());
471    }
472
473    #[test]
474    fn asm_validation_rejects_mutated_composition_typing() {
475        let mut asm = build_application_semantic_model(&presolve_parser::parse_file(
476            "src/ValidateCompositionTypes.tsx",
477            r#"
478@component("x-card") class Card extends Component { render() { return <div />; } }
479@component("x-page") class Page extends Component { render() { return <Card />; } }
480"#,
481        ));
482        asm.composition_types
483            .invocations
484            .values_mut()
485            .next()
486            .unwrap()
487            .target_boundary = None;
488        assert!(validate_application_semantic_model(&asm)
489            .iter()
490            .any(|diagnostic| diagnostic.code == "PSASM1196"));
491    }
492}