Skip to main content

presolve_compiler/
component_inheritance_lowering.rs

1//! Decorator-free V2 component recognition at the syntax/TypeScript boundary.
2//!
3//! The parser selects every class heritage clause from the general source AST.
4//! A TypeScript-authority client then proves which clauses resolve (directly or
5//! indirectly) to Presolve's exported `Component` base.  This module joins
6//! those products without looking at a base-class spelling, import path, or
7//! decorator.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use presolve_parser::{ParsedFile, SourceSpan};
12
13use crate::{
14    normalize_authored_semantics_v1, AuthoredSemanticCandidateKindV1,
15    AuthoredSemanticNormalizationErrorV1, AuthoredSourceRangeV1, CanonicalAuthoredSemanticModelV1,
16    CanonicalIntrinsicKindV1, ResolvedAuthoredSemanticCandidateV1, ResolvedIntrinsicIdentityV1,
17};
18
19/// A source-AST-selected V2 class heritage clause awaiting TypeScript proof.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ComponentInheritanceSiteV1 {
22    pub subject: String,
23    pub declaration_source: AuthoredSourceRangeV1,
24    pub heritage_source: AuthoredSourceRangeV1,
25}
26
27/// TypeScript proof that a selected heritage clause resolves to `Component`.
28///
29/// The authority is responsible for walking indirect inheritance and aliases.
30/// This compiler boundary validates only the exact source-AST join.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ResolvedComponentInheritanceV1 {
33    pub heritage_source: AuthoredSourceRangeV1,
34    pub component_identity: ResolvedIntrinsicIdentityV1,
35}
36
37/// A validated canonical V2 component-recognition product.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ComponentInheritanceLoweringV1 {
40    pub sites: Vec<ComponentInheritanceSiteV1>,
41    pub model: CanonicalAuthoredSemanticModelV1,
42}
43
44/// A bad authority-to-source join for canonical component recognition.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ComponentInheritanceLoweringErrorV1 {
47    DuplicateResolution { start: usize, end: usize },
48    UnknownHeritageResolution { start: usize, end: usize },
49    InvalidAuthoredSemantics(AuthoredSemanticNormalizationErrorV1),
50}
51
52impl std::fmt::Display for ComponentInheritanceLoweringErrorV1 {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::DuplicateResolution { start, end } => {
56                write!(
57                    formatter,
58                    "duplicate component inheritance resolution at {start}..{end}"
59                )
60            }
61            Self::UnknownHeritageResolution { start, end } => write!(
62                formatter,
63                "component inheritance resolution has no source heritage clause at {start}..{end}"
64            ),
65            Self::InvalidAuthoredSemantics(error) => error.fmt(formatter),
66        }
67    }
68}
69
70impl std::error::Error for ComponentInheritanceLoweringErrorV1 {}
71
72/// Select all class heritage clauses without assigning framework meaning.
73#[must_use]
74pub fn component_inheritance_sites_v1(parsed: &ParsedFile) -> Vec<ComponentInheritanceSiteV1> {
75    let mut sites = parsed
76        .classes
77        .iter()
78        .filter_map(|class| {
79            let heritage = class.heritage.as_ref()?;
80            Some(ComponentInheritanceSiteV1 {
81                subject: class.name.clone(),
82                declaration_source: range(class.span),
83                heritage_source: range(heritage.span),
84            })
85        })
86        .collect::<Vec<_>>();
87    sites.sort_by_key(|site| {
88        (
89            site.heritage_source.start,
90            site.heritage_source.end,
91            site.subject.clone(),
92        )
93    });
94    sites
95}
96
97/// Lower only authority-proven V2 component inheritance into canonical semantics.
98///
99/// An unresolved class heritage is intentionally absent rather than guessed
100/// from its source spelling. Legacy decorators are not inspected here.
101pub fn lower_component_inheritance_v1(
102    parsed: &ParsedFile,
103    resolutions: impl IntoIterator<Item = ResolvedComponentInheritanceV1>,
104) -> Result<ComponentInheritanceLoweringV1, ComponentInheritanceLoweringErrorV1> {
105    let sites = component_inheritance_sites_v1(parsed);
106    let known_sites = sites
107        .iter()
108        .map(|site| range_key(site.heritage_source))
109        .collect::<BTreeSet<_>>();
110    let mut resolution_by_site = BTreeMap::new();
111    for resolution in resolutions {
112        let key = range_key(resolution.heritage_source);
113        if !known_sites.contains(&key) {
114            return Err(
115                ComponentInheritanceLoweringErrorV1::UnknownHeritageResolution {
116                    start: key.0,
117                    end: key.1,
118                },
119            );
120        }
121        if resolution_by_site.insert(key, resolution).is_some() {
122            return Err(ComponentInheritanceLoweringErrorV1::DuplicateResolution {
123                start: key.0,
124                end: key.1,
125            });
126        }
127    }
128
129    let candidates = sites.iter().filter_map(|site| {
130        let resolution = resolution_by_site.get(&range_key(site.heritage_source))?;
131        Some(ResolvedAuthoredSemanticCandidateV1 {
132            subject: site.subject.clone(),
133            source: site.declaration_source,
134            kind: AuthoredSemanticCandidateKindV1::ResolvedIntrinsic {
135                intrinsic_kind: CanonicalIntrinsicKindV1::Component,
136                intrinsic_identity: resolution.component_identity.clone(),
137            },
138        })
139    });
140    let model = normalize_authored_semantics_v1(parsed, candidates)
141        .map_err(ComponentInheritanceLoweringErrorV1::InvalidAuthoredSemantics)?;
142    Ok(ComponentInheritanceLoweringV1 { sites, model })
143}
144
145fn range(span: SourceSpan) -> AuthoredSourceRangeV1 {
146    AuthoredSourceRangeV1 {
147        start: span.start,
148        end: span.end,
149        line: span.line,
150        column: span.column,
151    }
152}
153
154fn range_key(range: AuthoredSourceRangeV1) -> (usize, usize) {
155    (range.start, range.end)
156}
157
158#[cfg(test)]
159mod tests {
160    use presolve_parser::parse_file;
161
162    use crate::{CanonicalAuthoredDeclarationKindV1, ResolvedIntrinsicIdentityV1};
163
164    use super::{
165        component_inheritance_sites_v1, lower_component_inheritance_v1,
166        ComponentInheritanceLoweringErrorV1, ResolvedComponentInheritanceV1,
167    };
168
169    fn resolution(start: usize, end: usize) -> ResolvedComponentInheritanceV1 {
170        ResolvedComponentInheritanceV1 {
171            heritage_source: crate::AuthoredSourceRangeV1 {
172                start,
173                end,
174                line: 1,
175                column: start + 1,
176            },
177            component_identity: ResolvedIntrinsicIdentityV1 {
178                name: "Component".to_owned(),
179                flags: 32,
180                declaration_modules: vec!["node_modules/presolve/src/index.d.ts".to_owned()],
181            },
182        }
183    }
184
185    #[test]
186    fn lowers_only_authority_proven_decorator_free_component_inheritance() {
187        let source = r#"
188import { Component as FrameworkBase } from "presolve";
189class Base extends FrameworkBase {}
190export class Counter extends Base { render() { return <main />; } }
191@component() class LegacyOnly { render() { return <main />; } }
192class NotAPresolveComponent extends Other {}
193"#;
194        let parsed = parse_file("app/routes/index.tsx", source);
195        let sites = component_inheritance_sites_v1(&parsed);
196        assert_eq!(sites.len(), 3);
197        assert_eq!(sites[0].subject, "Base");
198        assert_eq!(sites[1].subject, "Counter");
199
200        let model = lower_component_inheritance_v1(
201            &parsed,
202            [resolution(
203                sites[1].heritage_source.start,
204                sites[1].heritage_source.end,
205            )],
206        )
207        .expect("TypeScript-proven indirect inheritance is canonical");
208
209        assert_eq!(model.model.declarations.len(), 1);
210        assert_eq!(
211            model.model.declarations[0].kind,
212            CanonicalAuthoredDeclarationKindV1::Component
213        );
214        assert_eq!(model.model.declarations[0].subject, "Counter");
215        assert!(model
216            .model
217            .declarations
218            .iter()
219            .all(|declaration| declaration.subject != "LegacyOnly"));
220    }
221
222    #[test]
223    fn rejects_duplicate_and_dangling_authority_joins() {
224        let parsed = parse_file("src/Card.tsx", "class Card extends FrameworkBase {}");
225        let site = component_inheritance_sites_v1(&parsed).pop().unwrap();
226        let duplicate = lower_component_inheritance_v1(
227            &parsed,
228            [
229                resolution(site.heritage_source.start, site.heritage_source.end),
230                resolution(site.heritage_source.start, site.heritage_source.end),
231            ],
232        )
233        .expect_err("each heritage clause can have one authority result");
234        assert!(matches!(
235            duplicate,
236            ComponentInheritanceLoweringErrorV1::DuplicateResolution { .. }
237        ));
238
239        let dangling = lower_component_inheritance_v1(&parsed, [resolution(0, 5)])
240            .expect_err("authority results must join a source-AST heritage clause");
241        assert!(matches!(
242            dangling,
243            ComponentInheritanceLoweringErrorV1::UnknownHeritageResolution { .. }
244        ));
245    }
246}