Skip to main content

presolve_compiler/
slot_content.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    AttributeValue, ComponentInvocationEntity, ComponentInvocationId,
5    ComponentInvocationResolutionStatus, ElementNode, FragmentNode, SemanticId,
6    SlotContentFragmentId, SlotEntity, SlotId, SlotOutletId, SourceProvenance, TemplateChild,
7    TemplateNode,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SlotContentFragmentStatus {
12    Resolved,
13    Blocked,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum SlotContentFragmentViolation {
18    UnresolvedInvocationTarget,
19    MissingSlotDeclaration,
20    DuplicateFragment,
21    InvalidNestedWrapper,
22    UnsupportedDynamicSlotName,
23    InvalidWrapperForm,
24}
25
26/// One caller-owned content range supplied to one invocation Slot name.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SlotContentFragment {
29    pub id: SlotContentFragmentId,
30    pub owner_component: SemanticId,
31    pub invocation: ComponentInvocationId,
32    pub requested_slot_name: String,
33    pub slot: Option<SlotId>,
34    pub template_fragment_root: SemanticId,
35    pub content_template_entities: Vec<SemanticId>,
36    pub status: SlotContentFragmentStatus,
37    pub violations: Vec<SlotContentFragmentViolation>,
38    pub provenance: SourceProvenance,
39    pub secondary_provenances: Vec<SourceProvenance>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SlotOutletStatus {
44    Resolved,
45    Blocked,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum SlotOutletViolation {
50    MissingSlotDeclaration,
51    DuplicateOutlet,
52    UnsupportedDynamicSlotName,
53    InvalidOutletAttributes,
54    UnsupportedFallback,
55}
56
57/// One callee-authored compile-time placement directive for a declared Slot.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct SlotOutlet {
60    pub id: SlotOutletId,
61    pub owner_component: SemanticId,
62    pub slot: Option<SlotId>,
63    pub requested_slot_name: String,
64    pub template_entity: SemanticId,
65    pub status: SlotOutletStatus,
66    pub violations: Vec<SlotOutletViolation>,
67    pub provenance: SourceProvenance,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct SlotCompositionRegistry {
72    pub fragments: BTreeMap<SlotContentFragmentId, SlotContentFragment>,
73    pub outlets: BTreeMap<SlotOutletId, SlotOutlet>,
74}
75
76#[derive(Debug, Clone)]
77struct RawFragment {
78    invocation: ComponentInvocationId,
79    requested_slot_name: String,
80    identity_name: String,
81    content_template_entities: Vec<SemanticId>,
82    violations: Vec<SlotContentFragmentViolation>,
83    provenance: SourceProvenance,
84}
85
86/// Lower H3 content and outlet facts from immutable template and invocation products.
87#[must_use]
88pub fn collect_slot_composition(
89    templates: &[TemplateNode],
90    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
91    slots: &BTreeMap<SlotId, SlotEntity>,
92) -> SlotCompositionRegistry {
93    let mut raw_fragments = Vec::new();
94    let mut outlets = Vec::new();
95
96    for template in templates {
97        if let Some(root) = &template.root {
98            collect_element_facts(
99                template,
100                root,
101                "root",
102                None,
103                false,
104                invocations,
105                slots,
106                &mut raw_fragments,
107                &mut outlets,
108            );
109        }
110        if let Some(root) = &template.root_fragment {
111            collect_fragment_facts(
112                template,
113                root,
114                "root",
115                None,
116                false,
117                invocations,
118                slots,
119                &mut raw_fragments,
120                &mut outlets,
121            );
122        }
123    }
124
125    let fragments = finalize_fragments(raw_fragments, invocations, slots);
126    mark_duplicate_outlets(&mut outlets);
127    SlotCompositionRegistry {
128        fragments,
129        outlets: outlets
130            .into_iter()
131            .map(|outlet| (outlet.id.clone(), outlet))
132            .collect(),
133    }
134}
135
136#[allow(clippy::too_many_arguments)]
137fn collect_element_facts(
138    template: &TemplateNode,
139    element: &ElementNode,
140    path: &str,
141    nearest_invocation: Option<&ComponentInvocationEntity>,
142    direct_child_of_invocation: bool,
143    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
144    slots: &BTreeMap<SlotId, SlotEntity>,
145    raw_fragments: &mut Vec<RawFragment>,
146    outlets: &mut Vec<SlotOutlet>,
147) {
148    let entity_id = template.id.template_entity("element", path);
149    let invocation_here = invocations
150        .values()
151        .find(|invocation| invocation.template_entity == entity_id);
152    let active_invocation = invocation_here.or(nearest_invocation);
153
154    if let Some(invocation) = invocation_here {
155        collect_direct_invocation_content(template, element, path, invocation, raw_fragments);
156    } else if element.tag_name == "template"
157        && has_attribute(element, "slot")
158        && !direct_child_of_invocation
159    {
160        if let Some(nearest_invocation) = nearest_invocation {
161            raw_fragments.push(raw_wrapper_fragment(
162                template,
163                element,
164                path,
165                nearest_invocation,
166                true,
167            ));
168        }
169    }
170
171    if element.tag_name == "slot" {
172        outlets.push(lower_outlet(template, element, path, slots));
173    }
174
175    for (index, child) in element.children.iter().enumerate() {
176        let child_path = format!("{path}.{index}");
177        collect_child_facts(
178            template,
179            child,
180            &child_path,
181            active_invocation,
182            invocation_here.is_some(),
183            invocations,
184            slots,
185            raw_fragments,
186            outlets,
187        );
188    }
189}
190
191#[allow(clippy::too_many_arguments)]
192fn collect_fragment_facts(
193    template: &TemplateNode,
194    fragment: &FragmentNode,
195    path: &str,
196    nearest_invocation: Option<&ComponentInvocationEntity>,
197    direct_child_of_invocation: bool,
198    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
199    slots: &BTreeMap<SlotId, SlotEntity>,
200    raw_fragments: &mut Vec<RawFragment>,
201    outlets: &mut Vec<SlotOutlet>,
202) {
203    for (index, child) in fragment.children.iter().enumerate() {
204        collect_child_facts(
205            template,
206            child,
207            &format!("{path}.{index}"),
208            nearest_invocation,
209            direct_child_of_invocation,
210            invocations,
211            slots,
212            raw_fragments,
213            outlets,
214        );
215    }
216}
217
218#[allow(clippy::too_many_arguments)]
219fn collect_child_facts(
220    template: &TemplateNode,
221    child: &TemplateChild,
222    path: &str,
223    nearest_invocation: Option<&ComponentInvocationEntity>,
224    direct_child_of_invocation: bool,
225    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
226    slots: &BTreeMap<SlotId, SlotEntity>,
227    raw_fragments: &mut Vec<RawFragment>,
228    outlets: &mut Vec<SlotOutlet>,
229) {
230    match child {
231        TemplateChild::Element(element) => collect_element_facts(
232            template,
233            element,
234            path,
235            nearest_invocation,
236            direct_child_of_invocation,
237            invocations,
238            slots,
239            raw_fragments,
240            outlets,
241        ),
242        TemplateChild::Fragment(fragment) => collect_fragment_facts(
243            template,
244            fragment,
245            path,
246            nearest_invocation,
247            direct_child_of_invocation,
248            invocations,
249            slots,
250            raw_fragments,
251            outlets,
252        ),
253        TemplateChild::Conditional(conditional) => {
254            for (index, child) in conditional.when_true.iter().enumerate() {
255                collect_child_facts(
256                    template,
257                    child,
258                    &format!("{path}.true.{index}"),
259                    nearest_invocation,
260                    false,
261                    invocations,
262                    slots,
263                    raw_fragments,
264                    outlets,
265                );
266            }
267            for (index, child) in conditional.when_false.iter().enumerate() {
268                collect_child_facts(
269                    template,
270                    child,
271                    &format!("{path}.false.{index}"),
272                    nearest_invocation,
273                    false,
274                    invocations,
275                    slots,
276                    raw_fragments,
277                    outlets,
278                );
279            }
280        }
281        TemplateChild::List(list) => {
282            for (index, child) in list.item_template.iter().enumerate() {
283                collect_child_facts(
284                    template,
285                    child,
286                    &format!("{path}.item.{index}"),
287                    nearest_invocation,
288                    false,
289                    invocations,
290                    slots,
291                    raw_fragments,
292                    outlets,
293                );
294            }
295        }
296        TemplateChild::Text { .. } | TemplateChild::Binding { .. } => {}
297    }
298}
299
300fn collect_direct_invocation_content(
301    template: &TemplateNode,
302    element: &ElementNode,
303    path: &str,
304    invocation: &ComponentInvocationEntity,
305    raw_fragments: &mut Vec<RawFragment>,
306) {
307    let mut default_entities = Vec::new();
308    let mut default_span = None;
309
310    for (index, child) in element.children.iter().enumerate() {
311        let child_path = format!("{path}.{index}");
312        let exact_wrapper = match child {
313            TemplateChild::Element(element)
314                if element.tag_name == "template" && has_attribute(element, "slot") =>
315            {
316                let raw = raw_wrapper_fragment(template, element, &child_path, invocation, false);
317                let is_exact = raw.violations.is_empty();
318                raw_fragments.push(raw);
319                is_exact
320            }
321            _ => false,
322        };
323        if exact_wrapper {
324            continue;
325        }
326        default_entities.push(child_entity_id(&template.id, child, &child_path));
327        default_span.get_or_insert_with(|| child_span(child));
328    }
329
330    if !default_entities.is_empty() {
331        raw_fragments.push(RawFragment {
332            invocation: invocation.id.clone(),
333            requested_slot_name: "children".to_string(),
334            identity_name: "children".to_string(),
335            content_template_entities: default_entities,
336            violations: Vec::new(),
337            provenance: SourceProvenance::new(
338                &template.provenance.path,
339                default_span.expect("non-empty default content has a span"),
340            ),
341        });
342    }
343}
344
345#[allow(clippy::single_match_else)]
346fn raw_wrapper_fragment(
347    template: &TemplateNode,
348    element: &ElementNode,
349    path: &str,
350    invocation: &ComponentInvocationEntity,
351    nested: bool,
352) -> RawFragment {
353    let slot_attributes = element
354        .attributes
355        .iter()
356        .filter(|attribute| attribute.name == "slot")
357        .collect::<Vec<_>>();
358    let mut violations = Vec::new();
359    let (requested_slot_name, identity_name) = match slot_attributes.as_slice() {
360        [attribute] => match &attribute.value {
361            AttributeValue::Static(name) if !name.is_empty() => (name.clone(), name.clone()),
362            AttributeValue::Binding { expression, .. } => {
363                violations.push(SlotContentFragmentViolation::UnsupportedDynamicSlotName);
364                (
365                    expression.clone(),
366                    format!("dynamic:{}", template.id.template_entity("element", path)),
367                )
368            }
369            _ => {
370                violations.push(SlotContentFragmentViolation::InvalidWrapperForm);
371                (
372                    "<invalid>".to_string(),
373                    format!("invalid:{}", template.id.template_entity("element", path)),
374                )
375            }
376        },
377        _ => {
378            violations.push(SlotContentFragmentViolation::InvalidWrapperForm);
379            (
380                "<invalid>".to_string(),
381                format!("invalid:{}", template.id.template_entity("element", path)),
382            )
383        }
384    };
385    if element.attributes.len() != 1 || nested {
386        violations.push(if nested {
387            SlotContentFragmentViolation::InvalidNestedWrapper
388        } else {
389            SlotContentFragmentViolation::InvalidWrapperForm
390        });
391    }
392    violations.sort_unstable();
393    violations.dedup();
394
395    RawFragment {
396        invocation: invocation.id.clone(),
397        requested_slot_name,
398        identity_name,
399        content_template_entities: element
400            .children
401            .iter()
402            .enumerate()
403            .map(|(index, child)| child_entity_id(&template.id, child, &format!("{path}.{index}")))
404            .collect(),
405        violations,
406        provenance: SourceProvenance::new(&template.provenance.path, element.tag_name_span),
407    }
408}
409
410fn finalize_fragments(
411    raw_fragments: Vec<RawFragment>,
412    invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
413    slots: &BTreeMap<SlotId, SlotEntity>,
414) -> BTreeMap<SlotContentFragmentId, SlotContentFragment> {
415    let mut grouped = BTreeMap::<(ComponentInvocationId, String), Vec<RawFragment>>::new();
416    for raw in raw_fragments {
417        grouped
418            .entry((raw.invocation.clone(), raw.identity_name.clone()))
419            .or_default()
420            .push(raw);
421    }
422
423    grouped
424        .into_iter()
425        .map(|((invocation_id, identity_name), mut candidates)| {
426            candidates.sort_by(|left, right| {
427                (
428                    left.provenance.path.as_path(),
429                    left.provenance.span.start,
430                    &left.requested_slot_name,
431                )
432                    .cmp(&(
433                        right.provenance.path.as_path(),
434                        right.provenance.span.start,
435                        &right.requested_slot_name,
436                    ))
437            });
438            let first = candidates.remove(0);
439            let invocation = invocations
440                .get(&invocation_id)
441                .expect("raw fragments originate from canonical invocations");
442            let mut violations = first.violations.clone();
443            if !candidates.is_empty() {
444                violations.push(SlotContentFragmentViolation::DuplicateFragment);
445            }
446            let slot = invocation.target_component.as_ref().and_then(|target| {
447                let id = SlotId::for_component(target, &first.requested_slot_name);
448                slots.contains_key(&id).then_some(id)
449            });
450            if invocation.status != ComponentInvocationResolutionStatus::Resolved {
451                violations.push(SlotContentFragmentViolation::UnresolvedInvocationTarget);
452            } else if slot.is_none()
453                && !violations.contains(&SlotContentFragmentViolation::UnsupportedDynamicSlotName)
454                && !violations.contains(&SlotContentFragmentViolation::InvalidWrapperForm)
455            {
456                violations.push(SlotContentFragmentViolation::MissingSlotDeclaration);
457            }
458            violations.sort_unstable();
459            violations.dedup();
460            let id = SlotContentFragmentId::for_invocation(&invocation_id, &identity_name);
461            let template_fragment_root = id
462                .as_semantic_id()
463                .template_entity("fragment-root", "content");
464            let fragment = SlotContentFragment {
465                id: id.clone(),
466                owner_component: invocation.owner_component.clone(),
467                invocation: invocation_id,
468                requested_slot_name: first.requested_slot_name,
469                slot,
470                template_fragment_root,
471                content_template_entities: first.content_template_entities,
472                status: if violations.is_empty() {
473                    SlotContentFragmentStatus::Resolved
474                } else {
475                    SlotContentFragmentStatus::Blocked
476                },
477                violations,
478                provenance: first.provenance,
479                secondary_provenances: candidates
480                    .into_iter()
481                    .map(|candidate| candidate.provenance)
482                    .collect(),
483            };
484            (id, fragment)
485        })
486        .collect()
487}
488
489fn lower_outlet(
490    template: &TemplateNode,
491    element: &ElementNode,
492    path: &str,
493    slots: &BTreeMap<SlotId, SlotEntity>,
494) -> SlotOutlet {
495    let owner_component = template
496        .owner
497        .entity_id()
498        .expect("component templates have component owners")
499        .clone();
500    let mut violations = Vec::new();
501    let (requested_slot_name, identity_name) = match element.attributes.as_slice() {
502        [] => ("children".to_string(), "children".to_string()),
503        [attribute] if attribute.name == "name" => match &attribute.value {
504            AttributeValue::Static(name) if !name.is_empty() => (name.clone(), name.clone()),
505            AttributeValue::Binding { expression, .. } => {
506                violations.push(SlotOutletViolation::UnsupportedDynamicSlotName);
507                (expression.clone(), format!("dynamic:{path}"))
508            }
509            _ => {
510                violations.push(SlotOutletViolation::InvalidOutletAttributes);
511                ("<invalid>".to_string(), format!("invalid:{path}"))
512            }
513        },
514        _ => {
515            violations.push(SlotOutletViolation::InvalidOutletAttributes);
516            ("<invalid>".to_string(), format!("invalid:{path}"))
517        }
518    };
519    if !element.children.is_empty() {
520        violations.push(SlotOutletViolation::UnsupportedFallback);
521    }
522    let slot_id = SlotId::for_component(&owner_component, &requested_slot_name);
523    let slot = slots.contains_key(&slot_id).then_some(slot_id);
524    if slot.is_none()
525        && !violations.contains(&SlotOutletViolation::UnsupportedDynamicSlotName)
526        && !violations.contains(&SlotOutletViolation::InvalidOutletAttributes)
527    {
528        violations.push(SlotOutletViolation::MissingSlotDeclaration);
529    }
530    violations.sort_unstable();
531    violations.dedup();
532    let template_entity = template.id.template_entity("element", path);
533    let id = SlotOutletId::for_template_entity(&template_entity, &identity_name);
534
535    SlotOutlet {
536        id,
537        owner_component,
538        slot,
539        requested_slot_name,
540        template_entity,
541        status: if violations.is_empty() {
542            SlotOutletStatus::Resolved
543        } else {
544            SlotOutletStatus::Blocked
545        },
546        violations,
547        provenance: SourceProvenance::new(&template.provenance.path, element.tag_name_span),
548    }
549}
550
551fn mark_duplicate_outlets(outlets: &mut [SlotOutlet]) {
552    let mut counts = BTreeMap::<(SemanticId, String), usize>::new();
553    for outlet in outlets.iter() {
554        *counts
555            .entry((
556                outlet.owner_component.clone(),
557                outlet.requested_slot_name.clone(),
558            ))
559            .or_default() += 1;
560    }
561    for outlet in outlets {
562        if counts[&(
563            outlet.owner_component.clone(),
564            outlet.requested_slot_name.clone(),
565        )] > 1
566        {
567            outlet.violations.push(SlotOutletViolation::DuplicateOutlet);
568            outlet.violations.sort_unstable();
569            outlet.violations.dedup();
570            outlet.status = SlotOutletStatus::Blocked;
571        }
572    }
573}
574
575fn has_attribute(element: &ElementNode, name: &str) -> bool {
576    element
577        .attributes
578        .iter()
579        .any(|attribute| attribute.name == name)
580}
581
582fn child_entity_id(template: &SemanticId, child: &TemplateChild, path: &str) -> SemanticId {
583    template.template_entity(
584        match child {
585            TemplateChild::Text { .. } => "text",
586            TemplateChild::Binding { .. } => "binding",
587            TemplateChild::Element(_) => "element",
588            TemplateChild::Fragment(_) => "fragment",
589            TemplateChild::Conditional(_) => "conditional",
590            TemplateChild::List(_) => "list",
591        },
592        path,
593    )
594}
595
596const fn child_span(child: &TemplateChild) -> presolve_parser::SourceSpan {
597    match child {
598        TemplateChild::Text { span, .. } | TemplateChild::Binding { span, .. } => *span,
599        TemplateChild::Element(element) => element.span,
600        TemplateChild::Fragment(fragment) => fragment.span,
601        TemplateChild::Conditional(conditional) => conditional.span,
602        TemplateChild::List(list) => list.span,
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use crate::{
609        build_application_semantic_model, build_semantic_graph,
610        validate_application_semantic_model, ComponentInvocationResolutionStatus,
611        SemanticEntityKind, SemanticOwner, SlotContentFragmentStatus, SlotContentFragmentViolation,
612        SlotKind, SlotOutletStatus, SlotOutletViolation,
613    };
614
615    #[test]
616    fn lowers_default_and_named_content_with_caller_ownership() {
617        let asm = build_application_semantic_model(&presolve_parser::parse_file(
618            "src/Composition.tsx",
619            r#"
620@component("x-card") class Card extends Component {
621  @slot() children!: SlotContent;
622  @slot() header!: SlotContent;
623  render() { return <article><slot name="header" /><slot /></article>; }
624}
625@component("x-page") class Page extends Component {
626  render() {
627    return <Card>before<span>body</span><template slot="header"><h1>Title</h1></template>after</Card>;
628  }
629}
630"#,
631        ));
632        let invocation = asm.component_invocations().into_iter().next().unwrap();
633        assert_eq!(
634            invocation.status,
635            ComponentInvocationResolutionStatus::Resolved
636        );
637        let fragments = asm.slot_content_fragments_for(&invocation.id);
638        assert_eq!(fragments.len(), 2);
639        let children = fragments
640            .iter()
641            .find(|fragment| fragment.requested_slot_name == "children")
642            .unwrap();
643        let header = fragments
644            .iter()
645            .find(|fragment| fragment.requested_slot_name == "header")
646            .unwrap();
647        assert_eq!(children.status, SlotContentFragmentStatus::Resolved);
648        assert_eq!(header.status, SlotContentFragmentStatus::Resolved);
649        assert_eq!(children.content_template_entities.len(), 3);
650        assert_eq!(header.content_template_entities.len(), 1);
651        assert_eq!(children.owner_component, invocation.owner_component);
652        assert_eq!(header.owner_component, invocation.owner_component);
653        assert_eq!(
654            asm.owner(&header.content_template_entities[0]),
655            Some(&SemanticOwner::entity(
656                invocation.owner_component.template()
657            ))
658        );
659        assert_eq!(header.slot.as_ref().unwrap(), &asm.slots()[1].id);
660        assert!(fragments.iter().all(|fragment| {
661            fragment
662                .slot
663                .as_ref()
664                .and_then(|slot| asm.slot(slot))
665                .is_some_and(|slot| matches!(slot.kind, SlotKind::Default | SlotKind::Named))
666        }));
667        assert_eq!(asm.slot_outlets().len(), 2);
668        assert!(asm
669            .slot_outlets()
670            .iter()
671            .all(|outlet| outlet.status == SlotOutletStatus::Resolved));
672        assert!(asm.slot_outlets().iter().all(|outlet| {
673            asm.owner(outlet.id.as_semantic_id())
674                == Some(&SemanticOwner::entity(outlet.owner_component.clone()))
675                && asm
676                    .entity(outlet.id.as_semantic_id())
677                    .is_some_and(|entity| entity.kind() == SemanticEntityKind::SlotOutlet)
678        }));
679        assert!(
680            validate_application_semantic_model(&asm).is_empty(),
681            "canonical Slot composition should pass ASM validation"
682        );
683        let graph = build_semantic_graph(&asm);
684        assert_eq!(graph.schema_version, 6);
685        assert!(graph.nodes.iter().all(|node| {
686            !asm.slot_content_fragments
687                .keys()
688                .any(|fragment| node.id == *fragment.as_semantic_id())
689                && !asm
690                    .slot_outlets
691                    .keys()
692                    .any(|outlet| node.id == *outlet.as_semantic_id())
693        }));
694    }
695
696    #[test]
697    fn retains_empty_missing_dynamic_nested_and_duplicate_fragment_facts() {
698        let asm = build_application_semantic_model(&presolve_parser::parse_file(
699            "src/InvalidContent.tsx",
700            r#"
701@component("x-card") class Card extends Component {
702  @slot() children!: SlotContent;
703  @slot() header!: SlotContent;
704  render() { return <article />; }
705}
706@component("x-page") class Page extends Component {
707  render() {
708    return <><Card /><Card><template slot="header" /><template slot="header" /></Card><Card><template slot="missing" /></Card><Card><template slot={this.name} /></Card><Card><div><template slot="header" /></div></Card></>;
709  }
710}
711"#,
712        ));
713        let invocations = asm.component_invocations();
714        assert_eq!(invocations.len(), 5);
715        assert!(asm
716            .slot_content_fragments_for(&invocations[0].id)
717            .is_empty());
718        let all = asm.slot_content_fragments();
719        assert!(all.iter().any(|fragment| fragment
720            .violations
721            .contains(&SlotContentFragmentViolation::DuplicateFragment)));
722        assert!(all.iter().any(|fragment| fragment
723            .violations
724            .contains(&SlotContentFragmentViolation::MissingSlotDeclaration)));
725        assert!(all.iter().any(|fragment| fragment
726            .violations
727            .contains(&SlotContentFragmentViolation::UnsupportedDynamicSlotName)));
728        assert!(all.iter().any(|fragment| fragment
729            .violations
730            .contains(&SlotContentFragmentViolation::InvalidNestedWrapper)));
731        assert!(all
732            .iter()
733            .filter(|fragment| fragment.requested_slot_name == "header")
734            .any(|fragment| !fragment.secondary_provenances.is_empty()));
735    }
736
737    #[test]
738    fn retains_missing_dynamic_duplicate_and_fallback_outlets() {
739        let asm = build_application_semantic_model(&presolve_parser::parse_file(
740            "src/InvalidOutlets.tsx",
741            r#"
742@component("x-panel") class Panel extends Component {
743  @slot() children!: SlotContent;
744  @slot() title!: SlotContent;
745  render() { return <main><slot /><slot /><slot name="missing" /><slot name={this.name} /><slot name="title">fallback</slot></main>; }
746}
747"#,
748        ));
749        let outlets = asm.slot_outlets();
750        assert_eq!(outlets.len(), 5);
751        assert!(outlets
752            .iter()
753            .filter(|outlet| outlet.requested_slot_name == "children")
754            .all(|outlet| outlet
755                .violations
756                .contains(&SlotOutletViolation::DuplicateOutlet)
757                && outlet.status == SlotOutletStatus::Blocked));
758        assert!(outlets.iter().any(|outlet| outlet
759            .violations
760            .contains(&SlotOutletViolation::MissingSlotDeclaration)));
761        assert!(outlets.iter().any(|outlet| outlet
762            .violations
763            .contains(&SlotOutletViolation::UnsupportedDynamicSlotName)));
764        assert!(outlets.iter().any(|outlet| outlet
765            .violations
766            .contains(&SlotOutletViolation::UnsupportedFallback)));
767    }
768
769    #[test]
770    fn keeps_ordinary_template_elements_as_default_content() {
771        let asm = build_application_semantic_model(&presolve_parser::parse_file(
772            "src/Templates.tsx",
773            r#"
774@component("x-card") class Card extends Component {
775  @slot() children!: SlotContent;
776  render() { return <article><slot /></article>; }
777}
778@component("x-page") class Page extends Component {
779  render() { return <Card><template data-kind="ordinary"><b>Body</b></template></Card>; }
780}
781"#,
782        ));
783        let invocation = asm.component_invocations().into_iter().next().unwrap();
784        let fragments = asm.slot_content_fragments_for(&invocation.id);
785
786        assert_eq!(fragments.len(), 1);
787        assert_eq!(fragments[0].requested_slot_name, "children");
788        assert_eq!(fragments[0].status, SlotContentFragmentStatus::Resolved);
789        assert!(fragments[0].violations.is_empty());
790        assert!(fragments[0].content_template_entities[0]
791            .as_str()
792            .contains("/element:"));
793    }
794
795    #[test]
796    fn blocks_content_for_an_unresolved_invocation_without_runtime_lookup() {
797        let asm = build_application_semantic_model(&presolve_parser::parse_file(
798            "src/Unresolved.tsx",
799            r#"
800@component("x-page") class Page extends Component {
801  render() { return <Missing>Body</Missing>; }
802}
803"#,
804        ));
805        let invocation = asm.component_invocations().into_iter().next().unwrap();
806        let fragment = asm
807            .slot_content_fragments_for(&invocation.id)
808            .into_iter()
809            .next()
810            .unwrap();
811
812        assert_eq!(fragment.status, SlotContentFragmentStatus::Blocked);
813        assert!(fragment
814            .violations
815            .contains(&SlotContentFragmentViolation::UnresolvedInvocationTarget));
816        assert!(fragment.slot.is_none());
817    }
818}