Skip to main content

presolve_compiler/
component_invocation.rs

1use std::collections::BTreeMap;
2
3use presolve_parser::ParsedFile;
4
5use crate::{
6    BindingTable, ComponentInvocationId, ComponentNode, ImportBindingTarget, SemanticId,
7    SourceProvenance, SymbolKind, TemplateNode, TemplatePositionId, TemplateSemanticEntity,
8    TemplateSemanticKind,
9};
10
11/// One immutable compiler-owned use of a component definition in a caller template.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ComponentInvocationEntity {
14    pub id: ComponentInvocationId,
15    pub owner_component: SemanticId,
16    pub target_component: Option<SemanticId>,
17    pub authored_symbol: String,
18    pub template_entity: SemanticId,
19    pub source_position: TemplatePositionId,
20    pub status: ComponentInvocationResolutionStatus,
21    pub provenance: SourceProvenance,
22    /// `true` only for compiler-issued file-route layout composition edges.
23    pub virtual_layout_composition: bool,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ComponentInvocationResolutionStatus {
28    Resolved,
29    UnresolvedSymbol,
30    ResolvedNonComponent,
31    Ambiguous,
32    UnsupportedDynamicTarget,
33}
34
35/// Lower `PascalCase` component uses from canonical template traversal and binding facts.
36#[must_use]
37pub fn collect_component_invocations(
38    components: &[ComponentNode],
39    templates: &[TemplateNode],
40    template_entities: &[TemplateSemanticEntity],
41    files: &[ParsedFile],
42    bindings: Option<&BindingTable>,
43    component_provenance: &BTreeMap<SemanticId, SourceProvenance>,
44) -> BTreeMap<ComponentInvocationId, ComponentInvocationEntity> {
45    template_entities
46        .iter()
47        .filter(|entity| entity.kind == TemplateSemanticKind::Element)
48        .filter_map(|entity| {
49            let authored_symbol = entity.tag_name.as_ref()?;
50            if !is_pascal_identifier(authored_symbol)
51                && !looks_like_dynamic_component_target(authored_symbol)
52            {
53                return None;
54            }
55
56            let template_id = entity.owner.entity_id()?;
57            let template = templates
58                .iter()
59                .find(|template| template.id == *template_id)?;
60            let owner_component = template.owner.entity_id()?.clone();
61            let (target_component, status) = resolve_invocation_target(
62                authored_symbol,
63                &entity.provenance.path,
64                components,
65                files,
66                bindings,
67                component_provenance,
68            );
69            let identity_target = target_component.as_ref().map_or_else(
70                || format!("{}:{}", status_identity_prefix(status), authored_symbol),
71                |target| target.as_str().to_string(),
72            );
73            let id = ComponentInvocationId::for_template_entity(&entity.id, &identity_target);
74            let source_position = TemplatePositionId::for_template_entity(&entity.id);
75            let provenance = entity
76                .tag_name_provenance
77                .clone()
78                .unwrap_or_else(|| entity.provenance.clone());
79
80            Some((
81                id.clone(),
82                ComponentInvocationEntity {
83                    id,
84                    owner_component,
85                    target_component,
86                    authored_symbol: authored_symbol.clone(),
87                    template_entity: entity.id.clone(),
88                    source_position,
89                    status,
90                    provenance,
91                    virtual_layout_composition: false,
92                },
93            ))
94        })
95        .collect()
96}
97
98fn resolve_invocation_target(
99    authored_symbol: &str,
100    module_path: &std::path::Path,
101    components: &[ComponentNode],
102    files: &[ParsedFile],
103    bindings: Option<&BindingTable>,
104    component_provenance: &BTreeMap<SemanticId, SourceProvenance>,
105) -> (Option<SemanticId>, ComponentInvocationResolutionStatus) {
106    if !is_pascal_identifier(authored_symbol) {
107        return (
108            None,
109            ComponentInvocationResolutionStatus::UnsupportedDynamicTarget,
110        );
111    }
112
113    let local_components = components
114        .iter()
115        .filter(|component| {
116            component.class_name == authored_symbol
117                && component.element_name.is_some()
118                && component_provenance
119                    .get(&component.id)
120                    .is_some_and(|provenance| provenance.path == module_path)
121        })
122        .collect::<Vec<_>>();
123    if local_components.len() > 1 {
124        return (None, ComponentInvocationResolutionStatus::Ambiguous);
125    }
126    if let Some(component) = local_components.first() {
127        return (
128            Some(component.id.clone()),
129            ComponentInvocationResolutionStatus::Resolved,
130        );
131    }
132
133    if let Some(binding) =
134        bindings.and_then(|bindings| bindings.resolve_import(module_path, authored_symbol))
135    {
136        return match &binding.target {
137            ImportBindingTarget::Symbol(symbol)
138                if symbol.kind == SymbolKind::Component
139                    && components.iter().any(|component| {
140                        component.id == symbol.id && component.element_name.is_some()
141                    }) =>
142            {
143                (
144                    Some(symbol.id.clone()),
145                    ComponentInvocationResolutionStatus::Resolved,
146                )
147            }
148            ImportBindingTarget::Symbol(_)
149            | ImportBindingTarget::Namespace { .. }
150            | ImportBindingTarget::SemanticPackage { .. } => (
151                None,
152                ComponentInvocationResolutionStatus::ResolvedNonComponent,
153            ),
154        };
155    }
156
157    let is_local_non_component = files
158        .iter()
159        .filter(|file| file.path == module_path)
160        .any(|file| {
161            file.type_aliases
162                .iter()
163                .any(|alias| alias.name == authored_symbol)
164                || file.classes.iter().any(|class| {
165                    class.name == authored_symbol
166                        && !local_components
167                            .iter()
168                            .any(|component| component.class_name == class.name)
169                })
170        });
171    if is_local_non_component {
172        return (
173            None,
174            ComponentInvocationResolutionStatus::ResolvedNonComponent,
175        );
176    }
177
178    (None, ComponentInvocationResolutionStatus::UnresolvedSymbol)
179}
180
181fn is_pascal_identifier(symbol: &str) -> bool {
182    symbol
183        .chars()
184        .next()
185        .is_some_and(|character| character.is_ascii_uppercase())
186        && symbol
187            .chars()
188            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'))
189}
190
191fn looks_like_dynamic_component_target(symbol: &str) -> bool {
192    let Some(final_segment) = symbol.rsplit(['.', ':']).next() else {
193        return false;
194    };
195    (symbol.contains('.') || symbol.contains(':'))
196        && final_segment
197            .chars()
198            .next()
199            .is_some_and(|character| character.is_ascii_uppercase())
200}
201
202const fn status_identity_prefix(status: ComponentInvocationResolutionStatus) -> &'static str {
203    match status {
204        ComponentInvocationResolutionStatus::Resolved => "resolved",
205        ComponentInvocationResolutionStatus::UnresolvedSymbol => "unresolved",
206        ComponentInvocationResolutionStatus::ResolvedNonComponent => "non-component",
207        ComponentInvocationResolutionStatus::Ambiguous => "ambiguous",
208        ComponentInvocationResolutionStatus::UnsupportedDynamicTarget => "dynamic",
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::{
215        build_application_semantic_model, build_application_semantic_model_for_unit,
216        build_semantic_graph, validate_application_semantic_model, CompilationUnit,
217        ComponentInvocationResolutionStatus, SemanticEntityKind,
218    };
219
220    #[test]
221    fn resolves_local_repeated_invocations_with_distinct_stable_identities() {
222        let source = r#"
223@component("x-card") class Card extends Component {
224  render() { return <article />; }
225}
226@component("x-page") class Page extends Component {
227  render() { return <main><Card /><Card /></main>; }
228}
229"#;
230        let first =
231            build_application_semantic_model(&presolve_parser::parse_file("src/Page.tsx", source));
232        let second = build_application_semantic_model(&presolve_parser::parse_file(
233            "src/Page.tsx",
234            &source.replace(
235                "render() { return <main>",
236                "title = state(\"x\"); render() { return <main>",
237            ),
238        ));
239        let invocations = first.component_invocations();
240
241        assert_eq!(invocations.len(), 2);
242        assert!(invocations.iter().all(|invocation| {
243            invocation.status == ComponentInvocationResolutionStatus::Resolved
244                && invocation
245                    .target_component
246                    .as_ref()
247                    .is_some_and(|target| target.as_str().ends_with("component:x-card"))
248                && invocation
249                    .owner_component
250                    .as_str()
251                    .ends_with("component:x-page")
252        }));
253        assert_ne!(invocations[0].id, invocations[1].id);
254        assert_ne!(
255            invocations[0].source_position,
256            invocations[1].source_position
257        );
258        assert_eq!(
259            first
260                .component_invocations
261                .keys()
262                .map(crate::ComponentInvocationId::as_str)
263                .collect::<Vec<_>>(),
264            second
265                .component_invocations
266                .keys()
267                .map(crate::ComponentInvocationId::as_str)
268                .collect::<Vec<_>>()
269        );
270        assert!(first
271            .entity(invocations[0].id.as_semantic_id())
272            .is_some_and(|entity| entity.kind() == SemanticEntityKind::ComponentInvocation));
273        assert!(
274            validate_application_semantic_model(&first).is_empty(),
275            "canonical component invocations should pass ASM validation"
276        );
277        let graph = build_semantic_graph(&first);
278        assert_eq!(graph.schema_version, 6);
279        assert!(graph.nodes.iter().all(|node| {
280            first
281                .component_invocations
282                .keys()
283                .all(|invocation| node.id != *invocation.as_semantic_id())
284        }));
285    }
286
287    #[test]
288    fn resolves_import_aliases_and_retains_every_blocked_resolution_status() {
289        let unit = CompilationUnit::parse_sources([
290            (
291                "src/Card.tsx",
292                r#"
293@component("x-card")
294export class Card extends Component {
295  render() { return <article />; }
296}
297export type Model = string;
298"#,
299            ),
300            (
301                "src/Page.tsx",
302                r#"
303import { Card as ImportedCard, Model } from "./Card";
304class Utility {}
305@component("x-page") class Page extends Component {
306  render() {
307    return <><ImportedCard /><Missing /><Model /><Utility /><Registry.Card /></>;
308  }
309}
310"#,
311            ),
312        ]);
313        let asm = build_application_semantic_model_for_unit(&unit);
314        let by_symbol = asm
315            .component_invocations()
316            .into_iter()
317            .map(|invocation| (invocation.authored_symbol.as_str(), invocation))
318            .collect::<std::collections::BTreeMap<_, _>>();
319
320        assert_eq!(
321            by_symbol["ImportedCard"].status,
322            ComponentInvocationResolutionStatus::Resolved
323        );
324        assert_eq!(
325            by_symbol["Missing"].status,
326            ComponentInvocationResolutionStatus::UnresolvedSymbol
327        );
328        assert_eq!(
329            by_symbol["Model"].status,
330            ComponentInvocationResolutionStatus::ResolvedNonComponent
331        );
332        assert_eq!(
333            by_symbol["Utility"].status,
334            ComponentInvocationResolutionStatus::ResolvedNonComponent
335        );
336        assert_eq!(
337            by_symbol["Registry.Card"].status,
338            ComponentInvocationResolutionStatus::UnsupportedDynamicTarget
339        );
340        assert!(by_symbol["ImportedCard"]
341            .target_component
342            .as_ref()
343            .is_some_and(|target| target.as_str().ends_with("component:x-card")));
344        assert_eq!(
345            by_symbol["ImportedCard"].provenance.span.end
346                - by_symbol["ImportedCard"].provenance.span.start,
347            "ImportedCard".len()
348        );
349        assert!(by_symbol["Missing"].target_component.is_none());
350    }
351
352    #[test]
353    fn retains_ambiguous_local_component_resolution_without_selecting_a_target() {
354        let parsed = presolve_parser::parse_file(
355            "src/Ambiguous.tsx",
356            r#"
357@component("x-card-a") class Card extends Component { render() { return <a />; } }
358@component("x-card-b") class Card extends Component { render() { return <b />; } }
359@component("x-page") class Page extends Component { render() { return <Card />; } }
360"#,
361        );
362        let asm = build_application_semantic_model(&parsed);
363        let invocation = asm
364            .component_invocations()
365            .into_iter()
366            .find(|invocation| invocation.authored_symbol == "Card")
367            .expect("ambiguous invocation candidate");
368
369        assert_eq!(
370            invocation.status,
371            ComponentInvocationResolutionStatus::Ambiguous
372        );
373        assert!(invocation.target_component.is_none());
374        assert!(invocation.id.as_str().contains("ambiguous:Card"));
375    }
376
377    #[test]
378    fn invocation_order_is_deterministic_across_input_file_order() {
379        let sources = [
380            (
381                "src/Card.tsx",
382                "@component(\"x-card\")\nexport class Card extends Component { render() { return <article />; } }",
383            ),
384            (
385                "src/Page.tsx",
386                "import { Card } from \"./Card\"; @component(\"x-page\") class Page extends Component { render() { return <Card />; } }",
387            ),
388        ];
389        let forward =
390            build_application_semantic_model_for_unit(&CompilationUnit::parse_sources(sources));
391        let reverse = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
392            sources[1], sources[0],
393        ]));
394
395        assert_eq!(forward.component_invocations, reverse.component_invocations);
396    }
397}