Skip to main content

presolve_compiler/
context_resolution.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    ComponentScopeGraph, ConsumerEntity, ConsumerId, ContextEntity, ContextId, ExpressionGraph,
5    ProviderEntity, ProviderId, SemanticId, SourceProvenance,
6};
7
8/// Immutable compiler-owned Context binding result for one canonical Consumer.
9#[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/// The complete G4 result domain. No variant contains a runtime value, lookup,
19/// slot, or component instance.
20#[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/// Resolves Consumers using only the supplied immutable scope graph and
40/// canonical Provider/Context products.
41#[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}