1use std::collections::BTreeMap;
2
3use crate::{
4 ComponentScopeGraph, ConsumerEntity, ConsumerId, ContextEntity, ContextId, ExpressionGraph,
5 ProviderEntity, ProviderId, SemanticId, SourceProvenance,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ContextResolution {
11 pub consumer: ConsumerId,
12 pub context: Option<ContextId>,
13 pub result: ContextResolutionResult,
14 pub searched_scopes: Vec<SemanticId>,
15 pub provenance: SourceProvenance,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ContextResolutionResult {
22 Provider {
23 provider: ProviderId,
24 provider_owner: SemanticId,
25 distance: u32,
26 },
27 ContextDefault {
28 context: ContextId,
29 expression: SemanticId,
30 },
31 Unresolved,
32 Ambiguous {
33 providers: Vec<ProviderId>,
34 distance: u32,
35 },
36 InvalidContextReference,
37}
38
39#[must_use]
42pub fn collect_context_resolutions(
43 consumers: &BTreeMap<ConsumerId, ConsumerEntity>,
44 contexts: &BTreeMap<ContextId, ContextEntity>,
45 providers: &BTreeMap<ProviderId, ProviderEntity>,
46 expression_graph: &ExpressionGraph,
47 component_scope: &ComponentScopeGraph,
48) -> BTreeMap<ConsumerId, ContextResolution> {
49 let provider_index = provider_index(providers);
50 consumers
51 .iter()
52 .map(|(id, consumer)| {
53 (
54 id.clone(),
55 resolve_consumer(
56 consumer,
57 contexts,
58 expression_graph,
59 component_scope,
60 &provider_index,
61 ),
62 )
63 })
64 .collect()
65}
66
67fn provider_index(
68 providers: &BTreeMap<ProviderId, ProviderEntity>,
69) -> BTreeMap<(SemanticId, ContextId), Vec<ProviderId>> {
70 let mut index = BTreeMap::<(SemanticId, ContextId), Vec<ProviderId>>::new();
71 for provider in providers.values() {
72 let Some(owner) = provider.owner.entity_id() else {
73 continue;
74 };
75 index
76 .entry((owner.clone(), provider.context.clone()))
77 .or_default()
78 .push(provider.id.clone());
79 }
80 for ids in index.values_mut() {
81 ids.sort();
82 }
83 index
84}
85
86fn resolve_consumer(
87 consumer: &ConsumerEntity,
88 contexts: &BTreeMap<ContextId, ContextEntity>,
89 expression_graph: &ExpressionGraph,
90 component_scope: &ComponentScopeGraph,
91 provider_index: &BTreeMap<(SemanticId, ContextId), Vec<ProviderId>>,
92) -> ContextResolution {
93 let provenance = consumer.context_designator.provenance.clone();
94 let Some(context) = consumer.context().cloned() else {
95 return ContextResolution {
96 consumer: consumer.id.clone(),
97 context: None,
98 result: ContextResolutionResult::InvalidContextReference,
99 searched_scopes: Vec::new(),
100 provenance,
101 };
102 };
103 let Some(owner) = consumer.owner.entity_id() else {
104 return ContextResolution {
105 consumer: consumer.id.clone(),
106 context: Some(context),
107 result: ContextResolutionResult::Unresolved,
108 searched_scopes: Vec::new(),
109 provenance,
110 };
111 };
112 let searched_scopes = component_scope.ancestor_chain(owner);
113
114 for (distance, scope) in searched_scopes.iter().enumerate() {
115 let candidates = provider_index
116 .get(&(scope.clone(), context.clone()))
117 .cloned()
118 .unwrap_or_default();
119 match candidates.as_slice() {
120 [] => {}
121 [provider] => {
122 return ContextResolution {
123 consumer: consumer.id.clone(),
124 context: Some(context),
125 result: ContextResolutionResult::Provider {
126 provider: provider.clone(),
127 provider_owner: scope.clone(),
128 distance: u32::try_from(distance)
129 .expect("component scope depth should fit in u32"),
130 },
131 searched_scopes,
132 provenance,
133 };
134 }
135 _ => {
136 return ContextResolution {
137 consumer: consumer.id.clone(),
138 context: Some(context),
139 result: ContextResolutionResult::Ambiguous {
140 providers: candidates,
141 distance: u32::try_from(distance)
142 .expect("component scope depth should fit in u32"),
143 },
144 searched_scopes,
145 provenance,
146 };
147 }
148 }
149 }
150
151 let result = contexts
152 .get(&context)
153 .filter(|context_entity| context_entity.default_expression.is_some())
154 .and_then(|_| expression_graph.root_for(context.as_semantic_id()))
155 .cloned()
156 .map_or(ContextResolutionResult::Unresolved, |expression| {
157 ContextResolutionResult::ContextDefault {
158 context: context.clone(),
159 expression,
160 }
161 });
162 ContextResolution {
163 consumer: consumer.id.clone(),
164 context: Some(context),
165 result,
166 searched_scopes,
167 provenance,
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use std::collections::BTreeMap;
174
175 use super::{collect_context_resolutions, ContextResolutionResult};
176 use crate::{
177 build_application_semantic_model, validate_application_semantic_model, ComponentScopeGraph,
178 ConsumerId, ProviderId,
179 };
180
181 #[test]
182 fn resolves_same_component_provider_with_a_canonical_relation() {
183 let asm = build_application_semantic_model(&presolve_parser::parse_file(
184 "src/toolbar.tsx",
185 r#"
186@component("x-app-shell")
187class AppShell extends Component {
188 @context()
189 theme!: Theme;
190 render() { return <main />; }
191}
192@component("x-toolbar")
193class Toolbar extends Component {
194 @provide(AppShell.theme)
195 providedTheme: Theme = this.localTheme;
196 @consume(AppShell.theme)
197 theme!: Theme;
198 render() { return <main />; }
199}
200"#,
201 ));
202 let toolbar = &asm.components[1];
203 let consumer = ConsumerId::for_component(&toolbar.id, "theme");
204 let provider = ProviderId::for_component(&toolbar.id, "providedTheme");
205 let resolution = asm.context_resolution(&consumer).unwrap();
206
207 assert!(matches!(
208 resolution.result,
209 ContextResolutionResult::Provider {
210 provider: ref resolved,
211 distance: 0,
212 ..
213 } if *resolved == provider
214 ));
215 assert_eq!(asm.resolved_provider(&consumer), Some(&provider));
216 assert_eq!(asm.consumers_resolved_to(&provider), vec![&consumer]);
217 assert!(asm.references.iter().any(|reference| {
218 reference.kind == crate::SemanticReferenceKind::ResolvesToProvider
219 && reference.source == *consumer.as_semantic_id()
220 && reference.target == *provider.as_semantic_id()
221 && reference.provenance == resolution.provenance
222 }));
223 assert!(validate_application_semantic_model(&asm).is_empty());
224 }
225
226 #[test]
227 fn uses_context_default_only_when_no_visible_provider_exists() {
228 let asm = build_application_semantic_model(&presolve_parser::parse_file(
229 "src/toolbar.tsx",
230 r#"
231@component("x-app-shell")
232class AppShell extends Component {
233 @context()
234 locale: string = "en";
235 render() { return <main />; }
236}
237@component("x-toolbar")
238class Toolbar extends Component {
239 @consume(AppShell.locale)
240 locale!: string;
241 render() { return <main />; }
242}
243"#,
244 ));
245 let consumer = ConsumerId::for_component(&asm.components[1].id, "locale");
246 let context = asm.consumers()[0].context().unwrap().clone();
247
248 assert!(matches!(
249 asm.context_resolution(&consumer).unwrap().result,
250 ContextResolutionResult::ContextDefault {
251 context: ref resolved, ..
252 } if *resolved == context
253 ));
254 assert_eq!(asm.consumers_using_default(&context), vec![&consumer]);
255 assert!(asm.resolved_provider(&consumer).is_none());
256 assert!(validate_application_semantic_model(&asm).is_empty());
257 }
258
259 #[test]
260 fn leaves_cross_component_providers_invisible_without_a_scope_edge() {
261 let asm = build_application_semantic_model(&presolve_parser::parse_file(
262 "src/components.tsx",
263 r#"
264@component("x-app-shell")
265class AppShell extends Component {
266 @context()
267 theme!: Theme;
268 render() { return <main />; }
269}
270@component("x-boundary")
271class ThemeBoundary extends Component {
272 @provide(AppShell.theme)
273 providedTheme: Theme = this.localTheme;
274 render() { return <main />; }
275}
276@component("x-toolbar")
277class Toolbar extends Component {
278 @consume(AppShell.theme)
279 theme!: Theme;
280 render() { return <main />; }
281}
282"#,
283 ));
284 let consumer = ConsumerId::for_component(&asm.components[2].id, "theme");
285
286 assert!(matches!(
287 asm.context_resolution(&consumer).unwrap().result,
288 ContextResolutionResult::Unresolved
289 ));
290 assert_eq!(asm.unresolved_context_consumers(), vec![&consumer]);
291 }
292
293 #[test]
294 fn future_parent_scope_edges_use_nearest_provider_without_source_inference() {
295 let asm = build_application_semantic_model(&presolve_parser::parse_file(
296 "src/components.tsx",
297 r#"
298@component("x-app-shell")
299class AppShell extends Component {
300 @context()
301 theme!: Theme;
302 @provide(AppShell.theme)
303 rootTheme: Theme = this.rootTheme;
304 render() { return <main />; }
305}
306@component("x-settings")
307class SettingsArea extends Component {
308 @provide(AppShell.theme)
309 settingsTheme: Theme = this.settingsTheme;
310 render() { return <main />; }
311}
312@component("x-toolbar")
313class Toolbar extends Component {
314 @consume(AppShell.theme)
315 theme!: Theme;
316 render() { return <main />; }
317}
318"#,
319 ));
320 let root = &asm.components[0].id;
321 let settings = &asm.components[1].id;
322 let toolbar = &asm.components[2].id;
323 let scope = ComponentScopeGraph::with_parent_relations(
324 &asm.components,
325 BTreeMap::from([
326 (toolbar.clone(), settings.clone()),
327 (settings.clone(), root.clone()),
328 ]),
329 );
330 let resolutions = collect_context_resolutions(
331 &asm.consumers,
332 &asm.contexts,
333 &asm.providers,
334 &asm.expression_graph,
335 &scope,
336 );
337 let consumer = ConsumerId::for_component(toolbar, "theme");
338 let expected = ProviderId::for_component(settings, "settingsTheme");
339
340 assert!(matches!(
341 resolutions[&consumer].result,
342 ContextResolutionResult::Provider {
343 ref provider,
344 distance: 1,
345 ..
346 } if *provider == expected
347 ));
348 }
349
350 #[test]
351 fn retains_ambiguity_in_stable_provider_id_order() {
352 let asm = build_application_semantic_model(&presolve_parser::parse_file(
353 "src/toolbar.tsx",
354 r#"
355@component("x-app-shell")
356class AppShell extends Component {
357 @context()
358 theme!: Theme;
359 render() { return <main />; }
360}
361@component("x-toolbar")
362class Toolbar extends Component {
363 @provide(AppShell.theme)
364 firstTheme: Theme = this.firstTheme;
365 @consume(AppShell.theme)
366 theme!: Theme;
367 render() { return <main />; }
368}
369"#,
370 ));
371 let toolbar = &asm.components[1].id;
372 let mut providers = asm.providers.clone();
373 let mut duplicate = providers.values().next().unwrap().clone();
374 duplicate.id = ProviderId::for_component(toolbar, "secondTheme");
375 duplicate.name = "secondTheme".to_string();
376 duplicate.authored_field = toolbar.provider_field("secondTheme");
377 providers.insert(duplicate.id.clone(), duplicate);
378 let resolutions = collect_context_resolutions(
379 &asm.consumers,
380 &asm.contexts,
381 &providers,
382 &asm.expression_graph,
383 &asm.component_scope,
384 );
385 let consumer = ConsumerId::for_component(toolbar, "theme");
386
387 assert!(matches!(
388 resolutions[&consumer].result,
389 ContextResolutionResult::Ambiguous {
390 ref providers,
391 distance: 0,
392 } if providers.windows(2).all(|pair| pair[0] < pair[1])
393 ));
394 }
395
396 #[test]
397 fn excludes_invalid_context_references_from_resolution() {
398 let asm = build_application_semantic_model(&presolve_parser::parse_file(
399 "src/toolbar.tsx",
400 r#"
401@component("x-toolbar")
402class Toolbar extends Component {
403 @consume(AppShell.theme)
404 theme!: Theme;
405 render() { return <main />; }
406}
407"#,
408 ));
409 let consumer = ConsumerId::for_component(&asm.components[0].id, "theme");
410
411 assert!(asm.context_resolution(&consumer).is_none());
412 assert_eq!(
413 asm.context_declaration_candidates()
414 .invalid_candidates()
415 .len(),
416 1
417 );
418 }
419}