Skip to main content

presolve_compiler/
form.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    ComponentNode, ExecutionBoundary, FormDeclarationStatus, FormId, SemanticId, SemanticOwner,
5    SourceProvenance,
6};
7
8/// First-class immutable compiler-owned Form declaration.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct FormEntity {
11    pub id: FormId,
12    pub owner: SemanticOwner,
13    pub authored_field: SemanticId,
14    pub name: String,
15    pub provenance: SourceProvenance,
16    pub boundary: ExecutionBoundary,
17}
18
19/// Lower only valid normalized I2 candidates into canonical Form entities.
20/// Invalid and duplicate candidates remain on their owning component as
21/// immutable diagnostic inputs and never become executable products.
22///
23/// # Panics
24///
25/// Panics if a candidate marked valid is missing canonical owner, Form, or
26/// authored-field identity. That would violate the I2 staging invariant.
27#[must_use]
28pub fn collect_form_entities(components: &[ComponentNode]) -> BTreeMap<FormId, FormEntity> {
29    components
30        .iter()
31        .flat_map(|component| component.form_declaration_candidates.iter())
32        .filter(|candidate| candidate.status == FormDeclarationStatus::Valid)
33        .map(|candidate| {
34            let id = candidate
35                .form_id
36                .clone()
37                .expect("valid Form candidate has canonical identity");
38            let owner = candidate
39                .owner_component
40                .clone()
41                .expect("valid Form candidate has canonical owner");
42            (
43                id.clone(),
44                FormEntity {
45                    id,
46                    owner: SemanticOwner::entity(owner),
47                    authored_field: candidate
48                        .authored_field
49                        .clone()
50                        .expect("valid Form candidate has authored field identity"),
51                    name: candidate
52                        .authored_name
53                        .clone()
54                        .expect("valid Form candidate has authored name"),
55                    provenance: candidate.provenance.clone(),
56                    boundary: ExecutionBoundary::Client,
57                },
58            )
59        })
60        .collect()
61}
62
63#[cfg(test)]
64mod tests {
65    use crate::{
66        build_application_semantic_model, build_application_semantic_model_for_unit,
67        build_semantic_graph, validate_application_semantic_model, AuthoredDeclarationKind,
68        CompilationUnit, ExecutionBoundary, FormDeclarationStatus, FormDeclarationViolation,
69        FormId, SemanticEntityKind, SemanticOwner, SemanticType, SEMANTIC_GRAPH_SCHEMA_VERSION,
70    };
71
72    #[test]
73    fn lowers_valid_form_fields_with_identity_ownership_type_and_provenance() {
74        let source = r#"
75@component("user-profile")
76class UserProfile {
77  @form()
78  profileForm!: Form;
79
80  @form()
81  declare settingsForm: Form;
82
83  render() { return <main />; }
84}
85"#;
86        let parsed = presolve_parser::parse_file("src/UserProfile.tsx", source);
87        let asm = build_application_semantic_model(&parsed);
88        let component = &asm.components[0];
89        let profile_id = FormId::for_owner(&component.id, "profileForm");
90        let profile = asm.form(&profile_id).expect("profile Form");
91
92        assert_eq!(asm.forms().len(), 2);
93        assert_eq!(
94            profile.id.as_str(),
95            "module:src/UserProfile.tsx/component:user-profile/form:profileForm"
96        );
97        assert_eq!(profile.owner, SemanticOwner::entity(component.id.clone()));
98        assert_eq!(
99            profile.authored_field.as_str(),
100            "module:src/UserProfile.tsx/component:user-profile/form-field:profileForm"
101        );
102        assert_eq!(profile.boundary, ExecutionBoundary::Client);
103        assert_eq!(
104            profile.provenance.span.start,
105            source.find("@form()\n  profileForm").unwrap()
106        );
107        assert_eq!(
108            asm.owner(profile.id.as_semantic_id()),
109            Some(&SemanticOwner::entity(component.id.clone()))
110        );
111        assert_eq!(
112            asm.semantic_type_of(profile.id.as_semantic_id()),
113            Some(&SemanticType::Form)
114        );
115        assert!(asm
116            .entity(profile.id.as_semantic_id())
117            .is_some_and(|entity| entity.kind() == SemanticEntityKind::Form));
118        assert!(asm
119            .form_declaration_candidates()
120            .iter()
121            .all(|candidate| candidate.status == FormDeclarationStatus::Valid));
122        assert!(validate_application_semantic_model(&asm).is_empty());
123        let graph = build_semantic_graph(&asm);
124        assert_eq!(graph.schema_version, SEMANTIC_GRAPH_SCHEMA_VERSION);
125        assert!(graph
126            .nodes
127            .iter()
128            .any(|node| node.id == *profile.id.as_semantic_id()));
129    }
130
131    #[test]
132    fn keeps_form_names_component_scoped_and_multi_file_order_deterministic() {
133        let login = r#"
134@component("login-panel")
135class LoginPanel {
136  @form() credentials!: Form;
137  render() { return <main />; }
138}
139"#;
140        let signup = r#"
141@component("signup-panel")
142class SignupPanel {
143  @form() credentials!: Form;
144  render() { return <main />; }
145}
146"#;
147        let first =
148            CompilationUnit::parse_sources([("src/Signup.tsx", signup), ("src/Login.tsx", login)]);
149        let second =
150            CompilationUnit::parse_sources([("src/Login.tsx", login), ("src/Signup.tsx", signup)]);
151        let first = build_application_semantic_model_for_unit(&first);
152        let second = build_application_semantic_model_for_unit(&second);
153
154        assert_eq!(first.forms, second.forms);
155        assert_eq!(
156            first.form_declaration_candidates(),
157            second.form_declaration_candidates()
158        );
159        assert_eq!(first.forms.len(), 2);
160        assert_ne!(first.forms.keys().next(), first.forms.keys().nth(1));
161        assert!(first.forms.values().all(|form| form.name == "credentials"));
162    }
163
164    #[test]
165    #[allow(clippy::too_many_lines)]
166    fn retains_every_invalid_form_candidate_without_fabricating_identities() {
167        let source = r#"
168@form()
169class ClassTarget {
170  @form() orphan!: Form;
171}
172
173@form()
174@component("profile")
175class Profile {
176  @form() good!: Form;
177  @form() static staticForm!: Form;
178  @form() initialized: Form = createForm();
179  @form() notDeclarationOnly: Form;
180  @form() missingType!;
181  @form() wrong!: string;
182  @form() nullable!: Form | null;
183  @form() generic!: Form<any>;
184  @form
185  bare!: Form;
186  @form("named") named!: Form;
187  @form({}, true) many!: Form;
188  @form() @slot() conflicting!: Form;
189  @form() duplicate!: Form;
190  @form() duplicate!: Form;
191  @form() ["computed"]!: Form;
192  @form() submit() {}
193  parameter(@form() value: Form) {}
194  @form() get current(): Form { return this.good; }
195  @form() set current(value: Form) {}
196  render() { return <main />; }
197}
198"#;
199        let parsed = presolve_parser::parse_file("src/InvalidForms.tsx", source);
200        let asm = build_application_semantic_model(&parsed);
201        let candidates = asm.form_declaration_candidates();
202
203        assert_eq!(asm.forms().len(), 1);
204        assert_eq!(asm.forms()[0].name, "good");
205        assert_eq!(candidates.len(), 22);
206        assert_eq!(
207            candidates
208                .iter()
209                .filter(|candidate| candidate.status == FormDeclarationStatus::Valid)
210                .count(),
211            1
212        );
213        assert!(candidates.iter().all(|candidate| {
214            candidate.status == FormDeclarationStatus::Valid || !candidate.violations().is_empty()
215        }));
216
217        for name in [
218            "staticForm",
219            "initialized",
220            "notDeclarationOnly",
221            "missingType",
222            "wrong",
223            "nullable",
224            "generic",
225            "bare",
226            "named",
227            "many",
228            "conflicting",
229            "duplicate",
230        ] {
231            assert!(
232                candidates.iter().any(|candidate| {
233                    candidate.authored_name.as_deref() == Some(name) && candidate.form_id.is_some()
234                }),
235                "{name}"
236            );
237        }
238        assert!(candidates
239            .iter()
240            .filter(|candidate| { candidate.authored_name.as_deref() == Some("duplicate") })
241            .all(|candidate| candidate
242                .violations()
243                .contains(&FormDeclarationViolation::DuplicateName)));
244        assert_eq!(
245            candidates
246                .iter()
247                .filter(|candidate| candidate.authored_name.as_deref() == Some("duplicate"))
248                .count(),
249            2
250        );
251        assert!(candidates.iter().any(|candidate| {
252            candidate.authored_name.as_deref() == Some("initialized")
253                && candidate
254                    .violations()
255                    .contains(&FormDeclarationViolation::InitializedField)
256        }));
257        assert!(candidates.iter().any(|candidate| {
258            candidate.authored_name.as_deref() == Some("bare")
259                && candidate
260                    .violations()
261                    .contains(&FormDeclarationViolation::InvalidDecoratorInvocation)
262        }));
263        assert!(candidates.iter().any(|candidate| {
264            candidate.authored_name.as_deref() == Some("conflicting")
265                && candidate
266                    .violations()
267                    .contains(&FormDeclarationViolation::ConflictingSemanticDecorator)
268        }));
269        assert_eq!(
270            candidates
271                .iter()
272                .find(|candidate| candidate.authored_name.as_deref() == Some("many"))
273                .expect("many-argument candidate")
274                .decorator_argument_provenance
275                .len(),
276            2
277        );
278        assert!(candidates
279            .iter()
280            .filter(|candidate| {
281                matches!(
282                    candidate.declaration_kind,
283                    AuthoredDeclarationKind::Class
284                        | AuthoredDeclarationKind::Method
285                        | AuthoredDeclarationKind::Getter
286                        | AuthoredDeclarationKind::Setter
287                        | AuthoredDeclarationKind::Parameter
288                ) || candidate
289                    .violations()
290                    .contains(&FormDeclarationViolation::InvalidOwner)
291                    || candidate
292                        .violations()
293                        .contains(&FormDeclarationViolation::UnsupportedFieldName)
294            })
295            .all(|candidate| candidate.form_id.is_none()));
296    }
297
298    #[test]
299    fn resolves_form_only_through_the_builtin_type_authority() {
300        let cases = [
301            r#"
302import { Form } from "./user-form";
303@component("profile")
304class Profile {
305  @form() profile!: Form;
306  render() { return <main />; }
307}
308"#,
309            r#"
310interface Form {}
311@component("profile")
312class Profile {
313  @form() profile!: Form;
314  render() { return <main />; }
315}
316"#,
317            r#"
318class Form {}
319@component("profile")
320class Profile {
321  @form() profile!: Form;
322  render() { return <main />; }
323}
324"#,
325            r#"
326class CustomForm extends Form {}
327@component("profile")
328class Profile {
329  @form() profile!: CustomForm;
330  render() { return <main />; }
331}
332"#,
333            r#"
334type FormAlias = Form;
335@component("profile")
336class Profile {
337  @form() profile!: FormAlias;
338  render() { return <main />; }
339}
340"#,
341        ];
342
343        for (index, source) in cases.into_iter().enumerate() {
344            let parsed = presolve_parser::parse_file(format!("src/InvalidType{index}.tsx"), source);
345            let asm = build_application_semantic_model(&parsed);
346            let candidates = asm.form_declaration_candidates();
347            assert!(asm.forms().is_empty(), "case {index}");
348            assert_eq!(candidates.len(), 1, "case {index}");
349            assert!(
350                candidates[0].violations().iter().any(|violation| matches!(
351                    violation,
352                    FormDeclarationViolation::InvalidType { .. }
353                )),
354                "case {index}"
355            );
356            assert!(candidates[0].form_id.is_some(), "case {index}");
357        }
358    }
359
360    #[test]
361    fn resolves_the_form_marker_from_the_public_presolve_package() {
362        let source = r#"
363import { Form } from "@presolve/core";
364@component("profile")
365class Profile {
366  @form() profile!: Form;
367  render() { return <main />; }
368}
369"#;
370        let parsed = presolve_parser::parse_file("src/Profile.tsx", source);
371        let asm = build_application_semantic_model(&parsed);
372        assert_eq!(asm.forms().len(), 1);
373        assert_eq!(asm.forms()[0].name, "profile");
374        assert_eq!(
375            asm.form_declaration_candidates()[0].status,
376            FormDeclarationStatus::Valid
377        );
378    }
379
380    #[test]
381    fn rejects_repeated_form_decorators_without_selecting_a_winner() {
382        let parsed = presolve_parser::parse_file(
383            "src/DuplicateDecorator.tsx",
384            r#"
385@component("profile")
386class Profile {
387  @form()
388  @form()
389  profile!: Form;
390  render() { return <main />; }
391}
392"#,
393        );
394        let asm = build_application_semantic_model(&parsed);
395        let candidates = asm.form_declaration_candidates();
396
397        assert!(asm.forms().is_empty());
398        assert_eq!(candidates.len(), 2);
399        assert!(candidates.iter().all(|candidate| {
400            candidate.form_id.is_some()
401                && candidate
402                    .violations()
403                    .contains(&FormDeclarationViolation::DuplicateFormDecorator)
404                && !candidate
405                    .violations()
406                    .contains(&FormDeclarationViolation::DuplicateName)
407        }));
408    }
409
410    #[test]
411    fn does_not_copy_inherited_form_declarations_into_derived_components() {
412        let parsed = presolve_parser::parse_file(
413            "src/Inheritance.tsx",
414            r#"
415@component("base-panel")
416class BasePanel {
417  @form() baseForm!: Form;
418  render() { return <main />; }
419}
420
421@component("derived-panel")
422class DerivedPanel extends BasePanel {
423  render() { return <main />; }
424}
425"#,
426        );
427        let asm = build_application_semantic_model(&parsed);
428        let derived = asm
429            .components
430            .iter()
431            .find(|component| component.class_name == "DerivedPanel")
432            .expect("derived component");
433
434        assert_eq!(asm.forms().len(), 1);
435        assert!(derived.form_declaration_candidates.is_empty());
436        assert!(asm.forms().iter().all(|form| {
437            form.owner
438                .entity_id()
439                .is_some_and(|owner| owner != &derived.id)
440        }));
441    }
442}