Skip to main content

presolve_compiler/
action_field_lowering.rs

1//! Decorator-free V2 `action(handler)` field recognition.
2//!
3//! This is the action counterpart to State initializer lowering: the parser
4//! selects direct instance-field calls, canonical component proof establishes
5//! ownership, and TypeScript-resolved identity supplies the framework meaning.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use presolve_parser::{ParsedFile, SourceSpan};
10
11use crate::{
12    normalize_authored_semantics_v1, AuthoredSemanticCandidateKindV1,
13    AuthoredSemanticNormalizationErrorV1, AuthoredSourceRangeV1,
14    CanonicalAuthoredDeclarationKindV1, CanonicalAuthoredSemanticModelV1, CanonicalIntrinsicKindV1,
15    ResolvedAuthoredSemanticCandidateV1, ResolvedIntrinsicIdentityV1,
16};
17
18/// A direct instance-field initializer call on an authority-proven component.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ActionFieldSiteV1 {
21    pub subject: String,
22    pub declaration_source: AuthoredSourceRangeV1,
23    pub callee_source: AuthoredSourceRangeV1,
24}
25
26/// TypeScript proof that a selected field-initializer callee is `action`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ResolvedActionFieldV1 {
29    pub callee_source: AuthoredSourceRangeV1,
30    pub action_identity: ResolvedIntrinsicIdentityV1,
31    pub terminal_package_invocation: Option<ResolvedTerminalPackageInvocationV1>,
32}
33
34/// TypeScript-authoritative identity for one discarded named-import call that
35/// is syntactically owned by the resolved Action handler.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ResolvedTerminalPackageInvocationV1 {
38    pub call_source: AuthoredSourceRangeV1,
39    pub module_specifier: String,
40    pub export_name: String,
41    pub declaration_modules: Vec<String>,
42}
43
44/// A validated canonical V2 Action-recognition product.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ActionFieldLoweringV1 {
47    pub sites: Vec<ActionFieldSiteV1>,
48    pub model: CanonicalAuthoredSemanticModelV1,
49}
50
51/// A bad component- or authority-to-source join for V2 Action recognition.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum ActionFieldLoweringErrorV1 {
54    ComponentSourcePathMismatch,
55    UnknownComponentDeclaration { start: usize, end: usize },
56    DuplicateResolution { start: usize, end: usize },
57    UnknownActionResolution { start: usize, end: usize },
58    InvalidAuthoredSemantics(AuthoredSemanticNormalizationErrorV1),
59}
60
61impl std::fmt::Display for ActionFieldLoweringErrorV1 {
62    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::ComponentSourcePathMismatch => write!(
65                formatter,
66                "component and Action authored-semantic products must describe the same source file"
67            ),
68            Self::UnknownComponentDeclaration { start, end } => write!(
69                formatter,
70                "canonical component declaration has no source class at {start}..{end}"
71            ),
72            Self::DuplicateResolution { start, end } => {
73                write!(
74                    formatter,
75                    "duplicate Action field resolution at {start}..{end}"
76                )
77            }
78            Self::UnknownActionResolution { start, end } => write!(
79                formatter,
80                "Action field resolution has no source field callee at {start}..{end}"
81            ),
82            Self::InvalidAuthoredSemantics(error) => error.fmt(formatter),
83        }
84    }
85}
86
87impl std::error::Error for ActionFieldLoweringErrorV1 {}
88
89/// Select direct instance-field calls owned by canonical V2 components.
90pub fn action_field_sites_v1(
91    parsed: &ParsedFile,
92    component_model: &CanonicalAuthoredSemanticModelV1,
93) -> Result<Vec<ActionFieldSiteV1>, ActionFieldLoweringErrorV1> {
94    if component_model.source_path != parsed.path {
95        return Err(ActionFieldLoweringErrorV1::ComponentSourcePathMismatch);
96    }
97    let components = component_model
98        .declarations
99        .iter()
100        .filter(|declaration| declaration.kind == CanonicalAuthoredDeclarationKindV1::Component)
101        .map(|declaration| (declaration.subject.clone(), range_key(declaration.source)))
102        .collect::<BTreeSet<_>>();
103    for (subject, declaration_key) in &components {
104        if !parsed
105            .classes
106            .iter()
107            .any(|class| class.name == *subject && range_key(range(class.span)) == *declaration_key)
108        {
109            return Err(ActionFieldLoweringErrorV1::UnknownComponentDeclaration {
110                start: declaration_key.0,
111                end: declaration_key.1,
112            });
113        }
114    }
115    let mut sites = parsed
116        .classes
117        .iter()
118        .filter(|class| components.contains(&(class.name.clone(), range_key(range(class.span)))))
119        .flat_map(|class| {
120            class.properties.iter().filter_map(move |property| {
121                let call = property.initializer_call.as_ref()?;
122                (!property.is_static).then_some(ActionFieldSiteV1 {
123                    subject: format!("{}.{}", class.name, property.name),
124                    declaration_source: range(property.span),
125                    callee_source: range(call.callee_span),
126                })
127            })
128        })
129        .collect::<Vec<_>>();
130    sites.sort_by_key(|site| {
131        (
132            site.callee_source.start,
133            site.callee_source.end,
134            site.subject.clone(),
135        )
136    });
137    Ok(sites)
138}
139
140/// Lower only authority-proven V2 Action field calls.
141pub fn lower_action_fields_v1(
142    parsed: &ParsedFile,
143    component_model: &CanonicalAuthoredSemanticModelV1,
144    resolutions: impl IntoIterator<Item = ResolvedActionFieldV1>,
145) -> Result<ActionFieldLoweringV1, ActionFieldLoweringErrorV1> {
146    let sites = action_field_sites_v1(parsed, component_model)?;
147    let known_sites = sites
148        .iter()
149        .map(|site| range_key(site.callee_source))
150        .collect::<BTreeSet<_>>();
151    let mut resolution_by_site = BTreeMap::new();
152    for resolution in resolutions {
153        let key = range_key(resolution.callee_source);
154        if !known_sites.contains(&key) {
155            return Err(ActionFieldLoweringErrorV1::UnknownActionResolution {
156                start: key.0,
157                end: key.1,
158            });
159        }
160        if resolution_by_site.insert(key, resolution).is_some() {
161            return Err(ActionFieldLoweringErrorV1::DuplicateResolution {
162                start: key.0,
163                end: key.1,
164            });
165        }
166    }
167    let candidates = sites.iter().filter_map(|site| {
168        let resolution = resolution_by_site.get(&range_key(site.callee_source))?;
169        Some(ResolvedAuthoredSemanticCandidateV1 {
170            subject: site.subject.clone(),
171            source: site.declaration_source,
172            kind: AuthoredSemanticCandidateKindV1::ResolvedIntrinsic {
173                intrinsic_kind: CanonicalIntrinsicKindV1::Action,
174                intrinsic_identity: resolution.action_identity.clone(),
175            },
176        })
177    });
178    let mut model = normalize_authored_semantics_v1(parsed, candidates)
179        .map_err(ActionFieldLoweringErrorV1::InvalidAuthoredSemantics)?;
180    for declaration in &mut model.declarations {
181        let Some(site) = sites
182            .iter()
183            .find(|site| site.subject == declaration.subject)
184        else {
185            continue;
186        };
187        let Some(invocation) = resolution_by_site
188            .get(&range_key(site.callee_source))
189            .and_then(|resolution| resolution.terminal_package_invocation.as_ref())
190        else {
191            continue;
192        };
193        declaration.derived_evidence = Some(
194            crate::DerivedAuthoredEvidenceV2::TerminalPackageInvocation {
195                module_specifier: invocation.module_specifier.clone(),
196                export_name: invocation.export_name.clone(),
197                declaration_modules: invocation.declaration_modules.clone(),
198            },
199        );
200    }
201    Ok(ActionFieldLoweringV1 { sites, model })
202}
203
204fn range(span: SourceSpan) -> AuthoredSourceRangeV1 {
205    AuthoredSourceRangeV1 {
206        start: span.start,
207        end: span.end,
208        line: span.line,
209        column: span.column,
210    }
211}
212
213fn range_key(range: AuthoredSourceRangeV1) -> (usize, usize) {
214    (range.start, range.end)
215}
216
217#[cfg(test)]
218mod tests {
219    use presolve_parser::parse_file;
220
221    use crate::{
222        lower_component_inheritance_v1, CanonicalAuthoredDeclarationKindV1,
223        ResolvedComponentInheritanceV1, ResolvedIntrinsicIdentityV1,
224    };
225
226    use super::{
227        action_field_sites_v1, lower_action_fields_v1, ActionFieldLoweringErrorV1,
228        ResolvedActionFieldV1,
229    };
230
231    fn identity(name: &str) -> ResolvedIntrinsicIdentityV1 {
232        ResolvedIntrinsicIdentityV1 {
233            name: name.to_owned(),
234            flags: 32,
235            declaration_modules: vec!["node_modules/presolve/src/index.d.ts".to_owned()],
236        }
237    }
238
239    #[test]
240    fn lowers_only_authority_proven_action_fields_of_canonical_components() {
241        let source = r#"
242class Counter extends Base {
243  increment = activate(() => {});
244  helper = ordinaryHelper(() => {});
245  static invalid = activate(() => {});
246}
247@component() class LegacyOnly { increment = activate(() => {}); }
248"#;
249        let parsed = parse_file("src/Counter.tsx", source);
250        let component_site = crate::component_inheritance_sites_v1(&parsed)
251            .into_iter()
252            .next()
253            .unwrap();
254        let components = lower_component_inheritance_v1(
255            &parsed,
256            [ResolvedComponentInheritanceV1 {
257                heritage_source: component_site.heritage_source,
258                component_identity: identity("Component"),
259            }],
260        )
261        .unwrap()
262        .model;
263        let sites = action_field_sites_v1(&parsed, &components).unwrap();
264        assert_eq!(sites.len(), 2);
265        assert_eq!(sites[0].subject, "Counter.increment");
266        assert_eq!(sites[1].subject, "Counter.helper");
267
268        let model = lower_action_fields_v1(
269            &parsed,
270            &components,
271            [ResolvedActionFieldV1 {
272                callee_source: sites[0].callee_source,
273                action_identity: identity("action"),
274                terminal_package_invocation: None,
275            }],
276        )
277        .unwrap();
278        assert_eq!(model.model.declarations.len(), 1);
279        assert_eq!(
280            model.model.declarations[0].kind,
281            CanonicalAuthoredDeclarationKindV1::Action
282        );
283        assert_eq!(model.model.declarations[0].subject, "Counter.increment");
284    }
285
286    #[test]
287    fn rejects_dangling_action_authority_results() {
288        let parsed = parse_file(
289            "src/Counter.tsx",
290            "class Counter extends Base { save = invoke(() => {}); }",
291        );
292        let component_site = crate::component_inheritance_sites_v1(&parsed)
293            .into_iter()
294            .next()
295            .unwrap();
296        let components = lower_component_inheritance_v1(
297            &parsed,
298            [ResolvedComponentInheritanceV1 {
299                heritage_source: component_site.heritage_source,
300                component_identity: identity("Component"),
301            }],
302        )
303        .unwrap()
304        .model;
305        let error = lower_action_fields_v1(
306            &parsed,
307            &components,
308            [ResolvedActionFieldV1 {
309                callee_source: crate::AuthoredSourceRangeV1 {
310                    start: 0,
311                    end: 4,
312                    line: 1,
313                    column: 1,
314                },
315                action_identity: identity("action"),
316                terminal_package_invocation: None,
317            }],
318        )
319        .expect_err("resolved actions must join a canonical component field");
320        assert!(matches!(
321            error,
322            ActionFieldLoweringErrorV1::UnknownActionResolution { .. }
323        ));
324    }
325}