Skip to main content

presolve_compiler/
effect_field_lowering.rs

1//! Decorator-free V2 `effect(handler)` field recognition.
2//!
3//! The parser selects direct instance-field calls, canonical component proof
4//! establishes ownership, and TypeScript-resolved identity supplies the
5//! framework meaning. Runtime adoption is deliberately a separate boundary.
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 EffectFieldSiteV1 {
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 `effect`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ResolvedEffectFieldV1 {
29    pub callee_source: AuthoredSourceRangeV1,
30    pub effect_identity: ResolvedIntrinsicIdentityV1,
31}
32
33/// A validated canonical V2 Effect-recognition product.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct EffectFieldLoweringV1 {
36    pub sites: Vec<EffectFieldSiteV1>,
37    pub model: CanonicalAuthoredSemanticModelV1,
38}
39
40/// A bad component- or authority-to-source join for V2 Effect recognition.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum EffectFieldLoweringErrorV1 {
43    ComponentSourcePathMismatch,
44    UnknownComponentDeclaration { start: usize, end: usize },
45    DuplicateResolution { start: usize, end: usize },
46    UnknownEffectResolution { start: usize, end: usize },
47    InvalidAuthoredSemantics(AuthoredSemanticNormalizationErrorV1),
48}
49
50impl std::fmt::Display for EffectFieldLoweringErrorV1 {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::ComponentSourcePathMismatch => write!(
54                formatter,
55                "component and Effect authored-semantic products must describe the same source file"
56            ),
57            Self::UnknownComponentDeclaration { start, end } => write!(
58                formatter,
59                "canonical component declaration has no source class at {start}..{end}"
60            ),
61            Self::DuplicateResolution { start, end } => {
62                write!(
63                    formatter,
64                    "duplicate Effect field resolution at {start}..{end}"
65                )
66            }
67            Self::UnknownEffectResolution { start, end } => write!(
68                formatter,
69                "Effect field resolution has no source field callee at {start}..{end}"
70            ),
71            Self::InvalidAuthoredSemantics(error) => error.fmt(formatter),
72        }
73    }
74}
75
76impl std::error::Error for EffectFieldLoweringErrorV1 {}
77
78/// Select direct instance-field calls owned by canonical V2 components.
79pub fn effect_field_sites_v1(
80    parsed: &ParsedFile,
81    component_model: &CanonicalAuthoredSemanticModelV1,
82) -> Result<Vec<EffectFieldSiteV1>, EffectFieldLoweringErrorV1> {
83    if component_model.source_path != parsed.path {
84        return Err(EffectFieldLoweringErrorV1::ComponentSourcePathMismatch);
85    }
86    let components = component_model
87        .declarations
88        .iter()
89        .filter(|declaration| declaration.kind == CanonicalAuthoredDeclarationKindV1::Component)
90        .map(|declaration| (declaration.subject.clone(), range_key(declaration.source)))
91        .collect::<BTreeSet<_>>();
92    for (subject, declaration_key) in &components {
93        if !parsed
94            .classes
95            .iter()
96            .any(|class| class.name == *subject && range_key(range(class.span)) == *declaration_key)
97        {
98            return Err(EffectFieldLoweringErrorV1::UnknownComponentDeclaration {
99                start: declaration_key.0,
100                end: declaration_key.1,
101            });
102        }
103    }
104    let mut sites = parsed
105        .classes
106        .iter()
107        .filter(|class| components.contains(&(class.name.clone(), range_key(range(class.span)))))
108        .flat_map(|class| {
109            class.properties.iter().filter_map(move |property| {
110                let call = property.initializer_call.as_ref()?;
111                (!property.is_static).then_some(EffectFieldSiteV1 {
112                    subject: format!("{}.{}", class.name, property.name),
113                    declaration_source: range(property.span),
114                    callee_source: range(call.callee_span),
115                })
116            })
117        })
118        .collect::<Vec<_>>();
119    sites.sort_by_key(|site| {
120        (
121            site.callee_source.start,
122            site.callee_source.end,
123            site.subject.clone(),
124        )
125    });
126    Ok(sites)
127}
128
129/// Lower only authority-proven V2 Effect field calls.
130pub fn lower_effect_fields_v1(
131    parsed: &ParsedFile,
132    component_model: &CanonicalAuthoredSemanticModelV1,
133    resolutions: impl IntoIterator<Item = ResolvedEffectFieldV1>,
134) -> Result<EffectFieldLoweringV1, EffectFieldLoweringErrorV1> {
135    let sites = effect_field_sites_v1(parsed, component_model)?;
136    let known_sites = sites
137        .iter()
138        .map(|site| range_key(site.callee_source))
139        .collect::<BTreeSet<_>>();
140    let mut resolution_by_site = BTreeMap::new();
141    for resolution in resolutions {
142        let key = range_key(resolution.callee_source);
143        if !known_sites.contains(&key) {
144            return Err(EffectFieldLoweringErrorV1::UnknownEffectResolution {
145                start: key.0,
146                end: key.1,
147            });
148        }
149        if resolution_by_site.insert(key, resolution).is_some() {
150            return Err(EffectFieldLoweringErrorV1::DuplicateResolution {
151                start: key.0,
152                end: key.1,
153            });
154        }
155    }
156    let candidates = sites.iter().filter_map(|site| {
157        let resolution = resolution_by_site.get(&range_key(site.callee_source))?;
158        Some(ResolvedAuthoredSemanticCandidateV1 {
159            subject: site.subject.clone(),
160            source: site.declaration_source,
161            kind: AuthoredSemanticCandidateKindV1::ResolvedIntrinsic {
162                intrinsic_kind: CanonicalIntrinsicKindV1::Effect,
163                intrinsic_identity: resolution.effect_identity.clone(),
164            },
165        })
166    });
167    let model = normalize_authored_semantics_v1(parsed, candidates)
168        .map_err(EffectFieldLoweringErrorV1::InvalidAuthoredSemantics)?;
169    Ok(EffectFieldLoweringV1 { sites, model })
170}
171
172fn range(span: SourceSpan) -> AuthoredSourceRangeV1 {
173    AuthoredSourceRangeV1 {
174        start: span.start,
175        end: span.end,
176        line: span.line,
177        column: span.column,
178    }
179}
180
181fn range_key(range: AuthoredSourceRangeV1) -> (usize, usize) {
182    (range.start, range.end)
183}
184
185#[cfg(test)]
186mod tests {
187    use presolve_parser::parse_file;
188
189    use crate::{
190        lower_component_inheritance_v1, CanonicalAuthoredDeclarationKindV1,
191        ResolvedComponentInheritanceV1, ResolvedIntrinsicIdentityV1,
192    };
193
194    use super::{effect_field_sites_v1, lower_effect_fields_v1, ResolvedEffectFieldV1};
195
196    fn identity(name: &str) -> ResolvedIntrinsicIdentityV1 {
197        ResolvedIntrinsicIdentityV1 {
198            name: name.to_owned(),
199            flags: 32,
200            declaration_modules: vec!["node_modules/presolve/src/index.d.ts".to_owned()],
201        }
202    }
203
204    #[test]
205    fn lowers_only_authority_proven_effect_fields_of_canonical_components() {
206        let parsed = parse_file(
207            "src/Counter.tsx",
208            r#"
209class Counter extends Base {
210  sync = observe(() => {});
211  helper = ordinaryHelper(() => {});
212  static invalid = observe(() => {});
213}
214@component() class LegacyOnly { sync = observe(() => {}); }
215"#,
216        );
217        let component_site = crate::component_inheritance_sites_v1(&parsed)
218            .into_iter()
219            .next()
220            .unwrap();
221        let components = lower_component_inheritance_v1(
222            &parsed,
223            [ResolvedComponentInheritanceV1 {
224                heritage_source: component_site.heritage_source,
225                component_identity: identity("Component"),
226            }],
227        )
228        .unwrap()
229        .model;
230        let sites = effect_field_sites_v1(&parsed, &components).unwrap();
231        assert_eq!(sites.len(), 2);
232        assert_eq!(sites[0].subject, "Counter.sync");
233        assert_eq!(sites[1].subject, "Counter.helper");
234
235        let model = lower_effect_fields_v1(
236            &parsed,
237            &components,
238            [ResolvedEffectFieldV1 {
239                callee_source: sites[0].callee_source,
240                effect_identity: identity("effect"),
241            }],
242        )
243        .unwrap();
244        assert_eq!(model.model.declarations.len(), 1);
245        assert_eq!(
246            model.model.declarations[0].kind,
247            CanonicalAuthoredDeclarationKindV1::Effect
248        );
249        assert_eq!(model.model.declarations[0].subject, "Counter.sync");
250    }
251}