Skip to main content

presolve_compiler/
context.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    ComponentNode, ContextId, DeclaredStateType, ExecutionBoundary, ExpressionGraph, SemanticId,
5    SemanticOwner, SemanticTypeId, SourceProvenance,
6};
7
8/// First-class compiler-owned semantic entity for one G1 `@context()` field.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ContextEntity {
11    pub id: ContextId,
12    pub owner: SemanticOwner,
13    pub authored_field: SemanticId,
14    pub name: String,
15    pub declared_type: DeclaredStateType,
16    pub declared_type_id: SemanticTypeId,
17    pub default_expression: Option<SemanticId>,
18    pub execution_boundary: ExecutionBoundary,
19    pub provenance: SourceProvenance,
20}
21
22/// Lower valid authored Context declarations into stable ASM entities.
23#[must_use]
24pub fn collect_context_entities(
25    components: &[ComponentNode],
26    expression_graph: &ExpressionGraph,
27) -> BTreeMap<ContextId, ContextEntity> {
28    components
29        .iter()
30        .flat_map(|component| {
31            component
32                .context_declarations
33                .iter()
34                .map(move |declaration| {
35                    let id = ContextId::for_component(&component.id, &declaration.name);
36                    let semantic_id = id.as_semantic_id().clone();
37                    (
38                        id.clone(),
39                        ContextEntity {
40                            declared_type_id: SemanticTypeId::for_subject(&semantic_id),
41                            default_expression: expression_graph.root_for(&semantic_id).cloned(),
42                            id,
43                            owner: SemanticOwner::entity(component.id.clone()),
44                            authored_field: declaration.authored_field.clone(),
45                            name: declaration.name.clone(),
46                            declared_type: declaration.declared_type.clone(),
47                            execution_boundary: ExecutionBoundary::Client,
48                            provenance: declaration.provenance.clone(),
49                        },
50                    )
51                })
52        })
53        .collect()
54}
55
56#[cfg(test)]
57mod tests {
58    use crate::{build_application_semantic_model, ContextId, ExecutionBoundary, SemanticOwner};
59
60    #[test]
61    fn lowers_typed_context_entities_with_literal_defaults() {
62        let parsed = presolve_parser::parse_file(
63            "src/AppShell.tsx",
64            r#"
65@component("x-app-shell")
66class AppShell extends Component {
67  @context()
68  theme!: Theme;
69
70  @context()
71  locale: string = "en";
72
73  render() { return <main />; }
74}
75"#,
76        );
77
78        let asm = build_application_semantic_model(&parsed);
79        let component = &asm.components[0];
80        let locale_id = ContextId::for_component(&component.id, "locale");
81        let locale = asm.context(&locale_id).expect("locale context");
82
83        assert_eq!(asm.contexts().len(), 2);
84        assert_eq!(
85            locale.id.as_str(),
86            "module:src/AppShell.tsx/component:x-app-shell/context:locale"
87        );
88        assert_eq!(locale.owner, SemanticOwner::entity(component.id.clone()));
89        assert_eq!(
90            locale.authored_field.as_str(),
91            "module:src/AppShell.tsx/component:x-app-shell/context-field:locale"
92        );
93        assert_eq!(locale.declared_type.text, "string");
94        assert_eq!(locale.execution_boundary, ExecutionBoundary::Client);
95        assert!(locale.default_expression.is_some());
96        assert_eq!(
97            asm.expression_owner(locale.default_expression.as_ref().unwrap()),
98            Some(locale.id.as_semantic_id())
99        );
100        assert!(component.state_fields.is_empty());
101    }
102
103    #[test]
104    fn retains_invalid_context_candidates_for_g18_diagnostics() {
105        let parsed = presolve_parser::parse_file(
106            "src/InvalidContexts.tsx",
107            r#"
108@component("x-invalid-contexts")
109class InvalidContexts extends Component {
110  @context("theme")
111  argument: string;
112
113  @context()
114  missingType;
115
116  @context()
117  static staticField: string;
118
119  @context()
120  nonliteral: string = createLocale();
121
122  @context()
123  get accessor(): string { return "en"; }
124
125  render() { return <main />; }
126}
127"#,
128        );
129
130        let asm = build_application_semantic_model(&parsed);
131
132        assert_eq!(asm.contexts().len(), 1);
133        assert_eq!(asm.contexts()[0].name, "staticField");
134        assert!(asm.components[0].state_fields.is_empty());
135        assert_eq!(
136            asm.context_declaration_candidates()
137                .invalid_candidates()
138                .len(),
139            4
140        );
141        assert_eq!(
142            asm.diagnostics
143                .iter()
144                .map(|diagnostic| diagnostic.code.as_str())
145                .collect::<Vec<_>>(),
146            vec!["PSC1052", "PSC1052", "PSC1052", "PSC1052"]
147        );
148    }
149
150    #[test]
151    fn accepts_static_contexts_and_qualified_string_designators() {
152        let parsed = presolve_parser::parse_file(
153            "src/Theme.tsx",
154            r#"
155@component("x-theme")
156class Theme extends Component {
157  @context()
158  static mode: string = "light";
159
160  @provide("Theme.mode")
161  providedMode: string = "dark";
162
163  @consume("Theme.mode")
164  consumedMode!: string;
165
166  render() { return <main />; }
167}
168"#,
169        );
170
171        let asm = build_application_semantic_model(&parsed);
172        let component = &asm.components[0];
173
174        assert_eq!(asm.contexts().len(), 1);
175        assert_eq!(component.provider_declarations.len(), 1);
176        assert_eq!(component.consumer_declarations.len(), 1);
177        assert_eq!(
178            component.provider_declarations[0]
179                .context_designator
180                .component_symbol,
181            "Theme"
182        );
183        assert_eq!(
184            component.consumer_declarations[0]
185                .context_designator
186                .context_member,
187            "mode"
188        );
189        assert!(asm.diagnostics.is_empty(), "{:#?}", asm.diagnostics);
190    }
191
192    #[test]
193    fn keeps_same_context_names_distinct_by_component() {
194        let parsed = presolve_parser::parse_file(
195            "src/Contexts.tsx",
196            r#"
197@component("x-left")
198class Left extends Component {
199  @context()
200  theme: string;
201  render() { return <main />; }
202}
203
204@component("x-right")
205class Right extends Component {
206  @context()
207  theme: string;
208  render() { return <main />; }
209}
210"#,
211        );
212
213        let asm = build_application_semantic_model(&parsed);
214
215        assert_eq!(asm.contexts().len(), 2);
216        assert_ne!(asm.contexts()[0].id, asm.contexts()[1].id);
217        assert!(asm.contexts().iter().all(|context| context.name == "theme"));
218    }
219
220    #[test]
221    fn asm_validation_accepts_context_ownership_type_and_default_contracts() {
222        let source = r#"
223@component("x-context-validation")
224class ContextValidation extends Component {
225  @context()
226  locale: string = "en";
227  render() { return <main />; }
228}
229"#;
230        let parsed = presolve_parser::parse_file("src/ContextValidation.tsx", source);
231        let asm = build_application_semantic_model(&parsed);
232        let context = asm.contexts()[0];
233
234        assert_eq!(
235            asm.semantic_type_of(context.id.as_semantic_id()),
236            Some(&crate::SemanticType::String)
237        );
238        assert_eq!(
239            context.provenance.span.start,
240            source.find("@context()").unwrap()
241        );
242        let diagnostics = crate::validate_application_semantic_model(&asm);
243        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
244    }
245}