1use std::collections::BTreeSet;
7
8use serde::Serialize;
9
10use crate::{
11 semantic_type_text, ApplicationSemanticModel, FormControlCompatibility, FormIrOperation,
12 RUNTIME_FORM_REGISTRY_VERSION,
13};
14
15pub const RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION: u32 = 2;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct RuntimeFormsArtifact {
19 pub schema_version: u32,
20 pub registry_version: u32,
21 pub forms: Vec<RuntimeFormsArtifactForm>,
22 pub instances: Vec<RuntimeFormsArtifactInstance>,
23 pub hosts: Vec<RuntimeFormsArtifactHost>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct RuntimeFormsArtifactForm {
30 pub id: String,
31 pub debug_name: String,
33 pub fields: Vec<RuntimeFormsArtifactField>,
34 pub bindings: Vec<RuntimeFormsArtifactBinding>,
35 pub validation_rules: Vec<RuntimeFormsArtifactRule>,
36 pub validation_dependencies: Vec<RuntimeFormsArtifactDependency>,
37 pub submission: Option<RuntimeFormsArtifactSubmission>,
38 pub serialization: RuntimeFormsArtifactSerialization,
39 pub reset: RuntimeFormsArtifactReset,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct RuntimeFormsArtifactField {
44 pub id: String,
45 pub debug_name: String,
47 pub path: Vec<String>,
49 pub semantic_type: String,
50 pub initial_value: crate::SerializableValue,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct RuntimeFormsArtifactBinding {
55 pub id: String,
56 pub control_anchor: String,
57 pub field: String,
58 pub channel: String,
59 pub normalization: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct RuntimeFormsArtifactRule {
64 pub id: String,
65 pub target_field: String,
66 pub kind: String,
67 pub argument: String,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub dependency: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct RuntimeFormsArtifactDependency {
74 pub id: String,
75 pub source_field: String,
76 pub target_field: String,
77 pub rule: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct RuntimeFormsArtifactSubmission {
82 pub plan: String,
83 pub action_batch: String,
84 pub validation_rules: Vec<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
88pub struct RuntimeFormsArtifactSerialization {
89 pub plan: String,
90 pub format: String,
91 pub fields: Vec<String>,
92 pub status: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
96pub struct RuntimeFormsArtifactReset {
97 pub plan: String,
98 pub fields: Vec<String>,
99 pub schedule_validation: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct RuntimeFormsArtifactInstance {
104 pub id: String,
105 pub form: String,
106 pub component_instance: String,
107 pub field_slots: Vec<RuntimeFormsArtifactFieldSlots>,
108 pub aggregate_validation_slot: String,
109 pub submission_slot: String,
110 pub programs: RuntimeFormsArtifactPrograms,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct RuntimeFormsArtifactHost {
115 pub id: String,
116 pub host_anchor: String,
117 pub form: String,
118 pub form_instance: String,
119 pub submission_plan: String,
120 pub submit_action: String,
121 pub action_batch: String,
122 pub serialization_plan: String,
123 pub event: String,
124 pub prevent_default: bool,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct RuntimeFormsArtifactFieldSlots {
129 pub field: String,
130 pub value: String,
131 pub dirty: String,
132 pub touched: String,
133 pub validation: String,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct RuntimeFormsArtifactPrograms {
138 pub initialize: Vec<String>,
139 pub input: Vec<RuntimeFormsArtifactFieldProgram>,
140 pub blur: Vec<RuntimeFormsArtifactFieldProgram>,
141 pub reset: Vec<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct RuntimeFormsArtifactFieldProgram {
146 pub field: String,
147 pub operations: Vec<String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct RuntimeFormsArtifactValidation {
152 pub diagnostics: Vec<String>,
153 pub is_valid: bool,
154}
155
156#[must_use]
165#[allow(clippy::too_many_lines)]
166pub fn build_runtime_forms_artifact(model: &ApplicationSemanticModel) -> RuntimeFormsArtifact {
167 let forms = model
168 .forms
169 .values()
170 .map(|form| {
171 let fields = model
172 .form_fields
173 .values()
174 .filter(|field| field.owner_form == form.id)
175 .map(|field| RuntimeFormsArtifactField {
176 id: field.id.to_string(),
177 debug_name: field.name.clone(),
178 path: field.path.clone(),
179 semantic_type: semantic_type_text(&field.semantic_type),
180 initial_value: field.initial_value.clone(),
181 })
182 .collect::<Vec<_>>();
183 let bindings = model
184 .form_field_bindings
185 .values()
186 .filter(|binding| binding.form == form.id)
187 .map(|binding| RuntimeFormsArtifactBinding {
188 id: binding.id.to_string(),
189 control_anchor: binding.control_entity.to_string(),
190 field: binding.field.to_string(),
191 channel: format!("{:?}", binding.channel),
192 normalization: normalization_name(binding.compatibility),
193 })
194 .collect();
195 let validation_rules = model
196 .validation_rules
197 .values()
198 .filter(|rule| rule.owner_form == form.id)
199 .map(|rule| RuntimeFormsArtifactRule {
200 id: rule.id.to_string(),
201 target_field: rule.target_field.to_string(),
202 kind: format!("{:?}", rule.kind),
203 argument: format!("{:?}", rule.argument),
204 dependency: rule.dependency.as_ref().map(ToString::to_string),
205 })
206 .collect();
207 let validation_dependencies = model
208 .validation_dependency_plans
209 .dependencies
210 .values()
211 .filter(|dependency| dependency.form == form.id)
212 .map(|dependency| RuntimeFormsArtifactDependency {
213 id: dependency.id.to_string(),
214 source_field: dependency.source_field.to_string(),
215 target_field: dependency.target_field.to_string(),
216 rule: dependency.dependent_rule.to_string(),
217 })
218 .collect();
219 let submission =
220 model
221 .submissions
222 .plan(&form.id)
223 .map(|plan| RuntimeFormsArtifactSubmission {
224 plan: plan.id.as_str().to_string(),
225 action_batch: plan.action_batch.to_string(),
226 validation_rules: plan
227 .validation_rules
228 .iter()
229 .map(ToString::to_string)
230 .collect(),
231 });
232 let serialization = model
233 .serialization
234 .plans
235 .get(&crate::SerializationPlanId::for_form(&form.id))
236 .expect("I10 creates one serialization plan per valid Form");
237 let reset = model
238 .reset
239 .plans
240 .get(&crate::ResetPlanId::for_form(&form.id))
241 .expect("I11 creates one reset plan per valid Form");
242 RuntimeFormsArtifactForm {
243 id: form.id.to_string(),
244 debug_name: form.name.clone(),
245 fields,
246 bindings,
247 validation_rules,
248 validation_dependencies,
249 submission,
250 serialization: RuntimeFormsArtifactSerialization {
251 plan: serialization.id.as_str().to_string(),
252 format: format!("{:?}", serialization.format),
253 fields: serialization
254 .fields
255 .iter()
256 .map(|field| field.field.to_string())
257 .collect(),
258 status: format!("{:?}", serialization.status),
259 },
260 reset: RuntimeFormsArtifactReset {
261 plan: reset.id.as_str().to_string(),
262 fields: reset
263 .operations
264 .iter()
265 .map(|operation| operation.field.to_string())
266 .collect(),
267 schedule_validation: reset.schedule_validation,
268 },
269 }
270 })
271 .collect();
272 let instances = model
273 .optimized_form_ir
274 .optimized
275 .instances
276 .values()
277 .map(|instance| RuntimeFormsArtifactInstance {
278 id: instance.id.to_string(),
279 form: instance.form.to_string(),
280 component_instance: instance.component_instance.to_string(),
281 field_slots: instance
282 .storage
283 .value
284 .iter()
285 .map(|(field, value)| RuntimeFormsArtifactFieldSlots {
286 field: field.to_string(),
287 value: value.as_str().to_string(),
288 dirty: instance.storage.dirty[field].as_str().to_string(),
289 touched: instance.storage.touched[field].as_str().to_string(),
290 validation: instance.storage.validation[field].as_str().to_string(),
291 })
292 .collect(),
293 aggregate_validation_slot: instance.storage.aggregate.as_str().to_string(),
294 submission_slot: instance.storage.submission.as_str().to_string(),
295 programs: RuntimeFormsArtifactPrograms {
296 initialize: operations(&instance.initialize),
297 input: field_programs(&instance.input),
298 blur: field_programs(&instance.blur),
299 reset: operations(&instance.reset),
300 },
301 })
302 .collect();
303 let hosts = model
304 .submission_hosts
305 .values()
306 .flat_map(|host| {
307 model
308 .optimized_form_ir
309 .optimized
310 .instances
311 .values()
312 .filter(move |instance| {
313 instance.form == host.form
314 && model
315 .component_instance_plan
316 .instances
317 .get(&instance.component_instance)
318 .is_some_and(|component| component.component == host.component)
319 })
320 .map(move |instance| RuntimeFormsArtifactHost {
321 id: host.id.to_string(),
322 host_anchor: runtime_anchor_for_element(
323 model,
324 &host.owner_template,
325 &host.owner_template_element,
326 )
327 .expect("valid host has exact template anchor"),
328 form: host.form.to_string(),
329 form_instance: instance.id.to_string(),
330 submission_plan: host.submission_plan.as_str().to_string(),
331 submit_action: host.submit_action.to_string(),
332 action_batch: host.action_batch.to_string(),
333 serialization_plan: host.serialization_plan.as_str().to_string(),
334 event: host.event.to_string(),
335 prevent_default: host.prevent_default,
336 })
337 })
338 .collect();
339 let artifact = RuntimeFormsArtifact {
340 schema_version: RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION,
341 registry_version: RUNTIME_FORM_REGISTRY_VERSION,
342 forms,
343 instances,
344 hosts,
345 };
346 debug_assert!(validate_runtime_forms_artifact(&artifact).is_valid);
347 artifact
348}
349
350#[allow(clippy::items_after_statements)]
351fn runtime_anchor_for_element(
352 model: &ApplicationSemanticModel,
353 template_id: &crate::SemanticId,
354 element: &crate::SemanticId,
355) -> Option<String> {
356 let template = model
357 .templates
358 .iter()
359 .find(|template| &template.id == template_id)?;
360 fn find(
361 element: &crate::ElementNode,
362 template: &crate::TemplateNode,
363 path: &str,
364 target: &crate::SemanticId,
365 ) -> Option<String> {
366 if template.id.template_entity("element", path) == *target {
367 return Some(element.id.0.clone());
368 }
369 for (index, child) in element.children.iter().enumerate() {
370 if let crate::TemplateChild::Element(child) = child {
371 if let Some(anchor) = find(child, template, &format!("{path}.{index}"), target) {
372 return Some(anchor);
373 }
374 }
375 }
376 None
377 }
378 template
379 .root
380 .as_ref()
381 .and_then(|root| find(root, template, "root", element))
382}
383
384#[must_use]
385pub fn validate_runtime_forms_artifact(
386 artifact: &RuntimeFormsArtifact,
387) -> RuntimeFormsArtifactValidation {
388 let mut diagnostics = Vec::new();
389 if artifact.schema_version != RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION {
390 diagnostics.push("unsupported Forms artifact schema".to_string());
391 }
392 let forms = artifact
393 .forms
394 .iter()
395 .map(|form| form.id.as_str())
396 .collect::<BTreeSet<_>>();
397 if forms.len() != artifact.forms.len() {
398 diagnostics.push("duplicate Form definition".to_string());
399 }
400 let instances = artifact
401 .instances
402 .iter()
403 .map(|instance| instance.id.as_str())
404 .collect::<BTreeSet<_>>();
405 if instances.len() != artifact.instances.len() {
406 diagnostics.push("duplicate Form instance".to_string());
407 }
408 for instance in &artifact.instances {
409 if !forms.contains(instance.form.as_str()) {
410 diagnostics.push(format!("unknown Form for instance {}", instance.id));
411 }
412 }
413 for form in &artifact.forms {
414 let paths = form
415 .fields
416 .iter()
417 .map(|field| &field.path)
418 .collect::<Vec<_>>();
419 if paths.iter().any(|path| {
420 !(1..=16).contains(&path.len())
421 || path
422 .iter()
423 .any(|segment| !is_canonical_form_field_path_segment(segment))
424 }) {
425 diagnostics.push(format!("invalid Form Field path for {}", form.id));
426 }
427 if paths.iter().enumerate().any(|(index, path)| {
428 paths
429 .iter()
430 .skip(index + 1)
431 .any(|other| form_field_paths_conflict(path, other))
432 }) {
433 diagnostics.push(format!("conflicting Form Field path for {}", form.id));
434 }
435 }
436 for host in &artifact.hosts {
437 if !instances.contains(host.form_instance.as_str())
438 || !forms.contains(host.form.as_str())
439 || host.event != "submit"
440 {
441 diagnostics.push(format!("invalid submission host {}", host.id));
442 }
443 }
444 RuntimeFormsArtifactValidation {
445 is_valid: diagnostics.is_empty(),
446 diagnostics,
447 }
448}
449
450fn is_canonical_form_field_path_segment(segment: &str) -> bool {
451 let mut characters = segment.chars();
452 matches!(characters.next(), Some(character) if character == '_' || character.is_ascii_alphabetic())
453 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
454}
455
456fn form_field_paths_conflict(left: &[String], right: &[String]) -> bool {
457 (left.len() <= right.len() && left.iter().zip(right).all(|(left, right)| left == right))
458 || (right.len() <= left.len() && right.iter().zip(left).all(|(left, right)| left == right))
459}
460
461#[must_use]
462pub fn runtime_forms_artifact_json(artifact: &RuntimeFormsArtifact) -> String {
467 serde_json::to_string_pretty(artifact).expect("Forms artifact serializes deterministically")
468 + "\n"
469}
470
471fn normalization_name(compatibility: FormControlCompatibility) -> String {
472 match compatibility {
473 FormControlCompatibility::Compatible(normalization) => format!("{normalization:?}"),
474 FormControlCompatibility::Incompatible => "Incompatible".to_string(),
475 }
476}
477
478fn operations(operations: &[FormIrOperation]) -> Vec<String> {
479 operations
480 .iter()
481 .map(|operation| format!("{operation:?}"))
482 .collect()
483}
484
485fn field_programs(
486 programs: &std::collections::BTreeMap<crate::FieldId, Vec<FormIrOperation>>,
487) -> Vec<RuntimeFormsArtifactFieldProgram> {
488 programs
489 .iter()
490 .map(
491 |(field, program_operations)| RuntimeFormsArtifactFieldProgram {
492 field: field.to_string(),
493 operations: operations(program_operations),
494 },
495 )
496 .collect()
497}
498
499#[cfg(test)]
500mod tests {
501 #[test]
502 fn emits_schema_v2_with_only_canonical_execution_references_and_field_paths() {
503 let parsed = presolve_parser::parse_file(
504 "src/X.tsx",
505 r#"@component("x")class X{@form()form!:Form;@field(this.form)value="";render(){return <input field={this.value}/>;}}"#,
506 );
507 let asm = crate::build_application_semantic_model(&parsed);
508 let artifact = super::build_runtime_forms_artifact(&asm);
509 assert_eq!(artifact.schema_version, 2);
510 assert_eq!(artifact.registry_version, 1);
511 assert_eq!(artifact.forms.len(), 1);
512 assert_eq!(artifact.instances.len(), 1);
513 assert!(super::validate_runtime_forms_artifact(&artifact).is_valid);
514 assert!(super::runtime_forms_artifact_json(&artifact).contains("field-binding"));
515 }
516
517 #[test]
518 fn rejects_noncanonical_and_prefix_conflicting_field_paths() {
519 let parsed = presolve_parser::parse_file(
520 "src/X.tsx",
521 r#"@component("x")class X{@form()form!:Form;@field(this.form)value="";render(){return <input field={this.value}/>;}}"#,
522 );
523 let asm = crate::build_application_semantic_model(&parsed);
524 let mut artifact = super::build_runtime_forms_artifact(&asm);
525 let field = artifact.forms[0].fields[0].clone();
526 artifact.forms[0].fields[0].path = vec!["invalid-path".to_string()];
527 assert!(!super::validate_runtime_forms_artifact(&artifact).is_valid);
528
529 artifact.forms[0].fields[0].path = vec!["address".to_string()];
530 let mut nested = field;
531 nested.path = vec!["address".to_string(), "street".to_string()];
532 artifact.forms[0].fields.push(nested);
533 assert!(!super::validate_runtime_forms_artifact(&artifact).is_valid);
534 }
535
536 #[test]
537 fn forms_products_are_byte_deterministic_when_input_files_are_reversed() {
538 let a = r#"@component("a") class A { @form() @serialize("json") profile!: Form; @field(this.profile) name = ""; @action() @submit(this.profile) save(): void {} render() { return <form form={this.profile}><input field={this.name}/></form>; } }"#;
539 let b = r#"@component("b") class B { @form() @serialize("url-encoded") search!: Form; @field(this.search) query = ""; @action() @submit(this.search) save(): void {} render() { return <form form={this.search}><input field={this.query}/></form>; } }"#;
540 let first = crate::CompilationUnit::parse_sources([("src/A.tsx", a), ("src/B.tsx", b)]);
541 let second = crate::CompilationUnit::parse_sources([("src/B.tsx", b), ("src/A.tsx", a)]);
542 let first = crate::build_application_semantic_model_for_unit(&first);
543 let second = crate::build_application_semantic_model_for_unit(&second);
544
545 assert_eq!(
546 super::runtime_forms_artifact_json(&super::build_runtime_forms_artifact(&first)),
547 super::runtime_forms_artifact_json(&super::build_runtime_forms_artifact(&second)),
548 );
549 assert_eq!(
550 crate::template_manifest_json(&crate::build_template_manifest_from_asm(&first)),
551 crate::template_manifest_json(&crate::build_template_manifest_from_asm(&second)),
552 );
553 assert_eq!(
554 crate::resume_manifest_json(&crate::build_resume_manifest(&first)),
555 crate::resume_manifest_json(&crate::build_resume_manifest(&second)),
556 );
557 }
558}