Skip to main content

presolve_compiler/
slot.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    ComponentNode, ExecutionBoundary, SemanticOwner, SemanticType, SlotId, SlotKind,
5    SourceProvenance,
6};
7
8/// First-class compiler-owned semantic entity for one valid H1 `@slot()` field.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct SlotEntity {
11    pub id: SlotId,
12    pub owner: crate::SemanticId,
13    pub authored_field: crate::SemanticId,
14    pub name: String,
15    pub kind: SlotKind,
16    pub semantic_type: SemanticType,
17    pub boundary: ExecutionBoundary,
18    pub declared_type_provenance: SourceProvenance,
19    pub provenance: SourceProvenance,
20}
21
22impl SlotEntity {
23    #[must_use]
24    pub fn semantic_owner(&self) -> SemanticOwner {
25        SemanticOwner::entity(self.owner.clone())
26    }
27}
28
29/// Lower valid authored Slot declarations into stable canonical entities.
30#[must_use]
31pub fn collect_slot_entities(components: &[ComponentNode]) -> BTreeMap<SlotId, SlotEntity> {
32    components
33        .iter()
34        .flat_map(|component| {
35            component.slot_declarations.iter().map(move |declaration| {
36                let id = SlotId::for_component(&component.id, &declaration.name);
37                (
38                    id.clone(),
39                    SlotEntity {
40                        id,
41                        owner: component.id.clone(),
42                        authored_field: declaration.authored_field.clone(),
43                        name: declaration.name.clone(),
44                        kind: declaration.kind,
45                        semantic_type: SemanticType::SlotContent,
46                        boundary: ExecutionBoundary::Client,
47                        declared_type_provenance: declaration.declared_type.provenance.clone(),
48                        provenance: declaration.provenance.clone(),
49                    },
50                )
51            })
52        })
53        .collect()
54}
55
56#[cfg(test)]
57mod tests {
58    use crate::{
59        build_application_semantic_model, build_semantic_graph,
60        validate_application_semantic_model, AuthoredDeclarationKind, ExecutionBoundary,
61        SemanticEntityKind, SemanticOwner, SemanticType, SlotDeclarationViolation, SlotId,
62        SlotKind,
63    };
64
65    #[test]
66    fn lowers_default_and_named_slots_with_canonical_identity_and_ownership() {
67        let parsed = presolve_parser::parse_file(
68            "src/Card.tsx",
69            r#"
70@component("x-card")
71class Card extends Component {
72  @slot()
73  children!: SlotContent;
74
75  @slot()
76  header!: SlotContent;
77
78  render() { return <section />; }
79}
80"#,
81        );
82        let asm = build_application_semantic_model(&parsed);
83        let component = &asm.components[0];
84        let children = asm
85            .slot(&SlotId::for_component(&component.id, "children"))
86            .expect("default slot");
87        let header = asm
88            .slot(&SlotId::for_component(&component.id, "header"))
89            .expect("named slot");
90
91        assert_eq!(asm.slots().len(), 2);
92        assert_eq!(children.kind, SlotKind::Default);
93        assert_eq!(header.kind, SlotKind::Named);
94        assert_eq!(children.semantic_type, SemanticType::SlotContent);
95        assert_eq!(children.boundary, ExecutionBoundary::Client);
96        assert_eq!(children.owner, component.id);
97        assert_eq!(children.authored_field, component.id.slot_field("children"));
98        assert_eq!(
99            children.id.as_str(),
100            "module:src/Card.tsx/component:x-card/slot:children"
101        );
102        assert_eq!(
103            asm.owner(children.id.as_semantic_id()),
104            Some(&SemanticOwner::entity(component.id.clone()))
105        );
106        assert_eq!(
107            asm.semantic_type_of(children.id.as_semantic_id()),
108            Some(&SemanticType::SlotContent)
109        );
110        assert!(asm
111            .entity(children.id.as_semantic_id())
112            .is_some_and(|entity| { entity.kind() == SemanticEntityKind::Slot }));
113        assert!(children.provenance.span.start < children.provenance.span.end);
114        assert!(
115            children.declared_type_provenance.span.start
116                < children.declared_type_provenance.span.end
117        );
118        let diagnostics = validate_application_semantic_model(&asm);
119        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
120    }
121
122    #[test]
123    fn retains_invalid_slot_field_shape_facts_without_creating_slot_ids() {
124        let parsed = presolve_parser::parse_file(
125            "src/InvalidSlots.tsx",
126            r#"
127@component("x-invalid-slots")
128class InvalidSlots extends Component {
129  @slot("header") argument!: SlotContent;
130  @slot() static staticSlot!: SlotContent;
131  @slot() missingType!;
132  @slot() wrongType!: string;
133  @slot() initialized: SlotContent = "bad";
134  @slot() notDefinite: SlotContent;
135  render() { return <main />; }
136}
137"#,
138        );
139        let asm = build_application_semantic_model(&parsed);
140        let candidates = &asm.components[0].slot_declaration_candidates;
141
142        assert!(asm.slots().is_empty());
143        assert_eq!(candidates.len(), 6);
144        assert!(candidates
145            .iter()
146            .any(
147                |candidate| candidate.violations.iter().any(|violation| matches!(
148                    violation,
149                    SlotDeclarationViolation::DecoratorArity { .. }
150                ))
151            ));
152        assert!(candidates
153            .iter()
154            .any(
155                |candidate| candidate.violations.iter().any(|violation| matches!(
156                    violation,
157                    SlotDeclarationViolation::StaticDeclarationUnsupported
158                ))
159            ));
160        assert_eq!(
161            candidates
162                .iter()
163                .filter(
164                    |candidate| candidate.violations.iter().any(|violation| matches!(
165                        violation,
166                        SlotDeclarationViolation::InvalidDeclaredType { .. }
167                    ))
168                )
169                .count(),
170            2
171        );
172        assert!(candidates.iter().any(|candidate| candidate
173            .violations
174            .iter()
175            .any(|violation| matches!(violation, SlotDeclarationViolation::ForbiddenInitializer))));
176        assert!(candidates
177            .iter()
178            .any(
179                |candidate| candidate.violations.iter().any(|violation| matches!(
180                    violation,
181                    SlotDeclarationViolation::DefiniteAssignmentRequired
182                ))
183            ));
184        assert!(candidates.iter().all(|candidate| {
185            !asm.slots.contains_key(&SlotId::for_component(
186                &candidate.owner_component,
187                candidate.field_name.as_deref().unwrap_or("invalid"),
188            ))
189        }));
190    }
191
192    #[test]
193    fn retains_slot_conflict_duplicate_and_invalid_declaration_kind_facts() {
194        let parsed = presolve_parser::parse_file(
195            "src/InvalidSlotKinds.tsx",
196            r#"
197@component("x-invalid-slot-kinds")
198class InvalidSlotKinds extends Component {
199  @slot() @context() conflict!: SlotContent;
200  @slot() duplicate!: SlotContent;
201  @slot() duplicate!: SlotContent;
202  @slot() method() {}
203  @slot() get accessor(): SlotContent { return this.value; }
204  attach(@slot() content: SlotContent) {}
205  render() { return <main />; }
206}
207"#,
208        );
209        let asm = build_application_semantic_model(&parsed);
210        let candidates = &asm.components[0].slot_declaration_candidates;
211
212        assert!(asm.slots().is_empty());
213        assert_eq!(candidates.len(), 6);
214        assert!(candidates.iter().any(|candidate| candidate
215            .violations
216            .contains(&SlotDeclarationViolation::ConflictingSemanticDecorators)));
217        assert_eq!(
218            candidates
219                .iter()
220                .filter(|candidate| candidate
221                    .violations
222                    .contains(&SlotDeclarationViolation::DuplicateSlot))
223                .count(),
224            2
225        );
226        assert!(candidates.iter().any(|candidate| {
227            candidate
228                .violations
229                .contains(&SlotDeclarationViolation::InvalidDeclarationKind {
230                    actual: AuthoredDeclarationKind::Method,
231                })
232        }));
233        assert!(candidates.iter().any(|candidate| {
234            candidate
235                .violations
236                .contains(&SlotDeclarationViolation::InvalidDeclarationKind {
237                    actual: AuthoredDeclarationKind::Getter,
238                })
239        }));
240        assert!(candidates.iter().any(|candidate| {
241            candidate
242                .violations
243                .contains(&SlotDeclarationViolation::InvalidDeclarationKind {
244                    actual: AuthoredDeclarationKind::Parameter,
245                })
246        }));
247    }
248
249    #[test]
250    fn slot_order_is_canonical_across_components_and_fields() {
251        let parsed = presolve_parser::parse_file(
252            "src/Slots.tsx",
253            r#"
254@component("x-z") class Z extends Component {
255  @slot() zebra!: SlotContent;
256  @slot() alpha!: SlotContent;
257  render() { return <main />; }
258}
259@component("x-a") class A extends Component {
260  @slot() children!: SlotContent;
261  render() { return <main />; }
262}
263"#,
264        );
265        let asm = build_application_semantic_model(&parsed);
266        let ids = asm.slots.keys().map(SlotId::as_str).collect::<Vec<_>>();
267        let mut sorted = ids.clone();
268        sorted.sort_unstable();
269        assert_eq!(ids, sorted);
270    }
271
272    #[test]
273    fn keeps_slots_out_of_the_frozen_phase_g_semantic_graph_schema() {
274        let parsed = presolve_parser::parse_file(
275            "src/Card.tsx",
276            r#"
277@component("x-card")
278class Card extends Component {
279  @slot() children!: SlotContent;
280  render() { return <section />; }
281}
282"#,
283        );
284        let asm = build_application_semantic_model(&parsed);
285        let slot_id = asm
286            .slots()
287            .into_iter()
288            .next()
289            .expect("slot entity")
290            .id
291            .clone();
292        let graph = build_semantic_graph(&asm);
293
294        assert_eq!(graph.schema_version, 6);
295        assert!(graph
296            .nodes
297            .iter()
298            .all(|node| node.id != *slot_id.as_semantic_id()));
299        assert!(graph.edges.iter().all(|edge| {
300            edge.source != *slot_id.as_semantic_id() && edge.target != *slot_id.as_semantic_id()
301        }));
302    }
303}