1use std::collections::BTreeMap;
2
3use crate::{
4 ComponentNode, ExecutionBoundary, FormDeclarationStatus, FormId, SemanticId, SemanticOwner,
5 SourceProvenance,
6};
7
8#[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#[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}