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