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