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