Skip to main content

presolve_compiler/
form_submission.rs

1//! I9 compiler-owned declaration-level Form submission planning.
2
3use std::collections::BTreeMap;
4
5use crate::component_graph::AuthoredSubmissionDeclarationFact;
6use crate::{
7    ComponentNode, EffectTriggerPlan, FieldId, FormEntity, FormFieldEntity, FormId, SemanticId,
8    SourceProvenance, SubmissionDeclarationCandidateId, SubmissionPlanId, ValidationRule,
9    ValidationRuleId,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub enum SubmissionDeclarationViolation {
14    InvalidOwner,
15    InvalidDecoratorInvocation,
16    InvalidDecoratorArity,
17    InvalidFormDesignator,
18    UnresolvedForm,
19    InvalidAction,
20    StaticMethod,
21    AsyncMethod,
22    ParameterizedMethod,
23    InvalidReturnType,
24    InheritedDeclaration,
25    DuplicateFormSubmission,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SubmissionDeclarationCandidate {
30    pub id: SubmissionDeclarationCandidateId,
31    pub owner_component: Option<SemanticId>,
32    pub method: Option<SemanticId>,
33    pub form_designator: Option<String>,
34    pub resolved_form: Option<FormId>,
35    pub action_batch: Option<SemanticId>,
36    pub provenance: SourceProvenance,
37    pub decorator_provenance: SourceProvenance,
38    pub form_designator_provenance: Option<SourceProvenance>,
39    pub violations: Vec<SubmissionDeclarationViolation>,
40}
41
42impl SubmissionDeclarationCandidate {
43    #[must_use]
44    pub fn is_valid(&self) -> bool {
45        self.violations.is_empty()
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SubmitResetPolicy {
51    Never,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct FormSubmissionPlan {
56    pub id: SubmissionPlanId,
57    pub form: FormId,
58    pub component: SemanticId,
59    pub candidate: SubmissionDeclarationCandidateId,
60    pub action_method: SemanticId,
61    pub action_batch: SemanticId,
62    pub validation_rules: Vec<ValidationRuleId>,
63    pub blocks_action_on_invalid: bool,
64    pub reset_policy: SubmitResetPolicy,
65    pub provenance: SourceProvenance,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct SubmissionProducts {
70    pub candidates: Vec<SubmissionDeclarationCandidate>,
71    pub plans: BTreeMap<SubmissionPlanId, FormSubmissionPlan>,
72}
73
74impl SubmissionProducts {
75    #[must_use]
76    pub fn plan(&self, form: &FormId) -> Option<&FormSubmissionPlan> {
77        self.plans.get(&SubmissionPlanId::for_form(form))
78    }
79}
80
81#[must_use]
82#[allow(clippy::too_many_lines)]
83/// # Panics
84///
85/// Panics when a candidate previously classified as valid lacks an exact Form,
86/// method, or action-batch identity. That violates I9 staged lowering.
87pub fn collect_submission_products(
88    components: &[ComponentNode],
89    forms: &BTreeMap<FormId, FormEntity>,
90    fields: &BTreeMap<FieldId, FormFieldEntity>,
91    rules: &BTreeMap<ValidationRuleId, ValidationRule>,
92    action_batches: &EffectTriggerPlan,
93) -> SubmissionProducts {
94    let mut candidates = components
95        .iter()
96        .flat_map(|component| component.submission_declaration_facts.iter())
97        .map(|fact| lower_candidate(fact, forms, action_batches))
98        .collect::<Vec<_>>();
99    candidates.sort_by(|left, right| {
100        (
101            left.provenance.path.as_path(),
102            left.decorator_provenance.span.start,
103            left.id.as_str(),
104        )
105            .cmp(&(
106                right.provenance.path.as_path(),
107                right.decorator_provenance.span.start,
108                right.id.as_str(),
109            ))
110    });
111
112    let mut groups = BTreeMap::<FormId, Vec<usize>>::new();
113    for (index, candidate) in candidates
114        .iter()
115        .enumerate()
116        .filter(|(_, candidate)| candidate.is_valid())
117    {
118        groups
119            .entry(
120                candidate
121                    .resolved_form
122                    .clone()
123                    .expect("valid submission resolves Form"),
124            )
125            .or_default()
126            .push(index);
127    }
128    for indexes in groups.values().filter(|indexes| indexes.len() > 1) {
129        for index in indexes {
130            add_violation(
131                &mut candidates[*index],
132                SubmissionDeclarationViolation::DuplicateFormSubmission,
133            );
134        }
135    }
136    let mut methods = BTreeMap::<SemanticId, Vec<usize>>::new();
137    for (index, candidate) in candidates
138        .iter()
139        .enumerate()
140        .filter(|(_, candidate)| candidate.is_valid())
141    {
142        methods
143            .entry(
144                candidate
145                    .method
146                    .clone()
147                    .expect("valid submission has method"),
148            )
149            .or_default()
150            .push(index);
151    }
152    for indexes in methods.values().filter(|indexes| indexes.len() > 1) {
153        for index in indexes {
154            add_violation(
155                &mut candidates[*index],
156                SubmissionDeclarationViolation::DuplicateFormSubmission,
157            );
158        }
159    }
160
161    let mut plans = BTreeMap::new();
162    for candidate in candidates.iter().filter(|candidate| candidate.is_valid()) {
163        let form = candidate
164            .resolved_form
165            .clone()
166            .expect("valid submission resolves Form");
167        let form_entity = forms.get(&form).expect("valid submission Form exists");
168        let action_method = candidate
169            .method
170            .clone()
171            .expect("valid submission has action method");
172        let action_batch = candidate
173            .action_batch
174            .clone()
175            .expect("valid submission has action batch");
176        let mut validation_rules = rules
177            .values()
178            .filter(|rule| rule.owner_form == form)
179            .collect::<Vec<_>>();
180        validation_rules.sort_by(|left, right| {
181            (
182                left.field_authored_order,
183                left.rule_authored_order,
184                &left.id,
185            )
186                .cmp(&(
187                    right.field_authored_order,
188                    right.rule_authored_order,
189                    &right.id,
190                ))
191        });
192        plans.insert(
193            SubmissionPlanId::for_form(&form),
194            FormSubmissionPlan {
195                id: SubmissionPlanId::for_form(&form),
196                form,
197                component: form_entity
198                    .owner
199                    .entity_id()
200                    .expect("valid Form has Component owner")
201                    .clone(),
202                candidate: candidate.id.clone(),
203                action_method,
204                action_batch,
205                validation_rules: validation_rules
206                    .into_iter()
207                    .map(|rule| rule.id.clone())
208                    .collect(),
209                blocks_action_on_invalid: true,
210                reset_policy: SubmitResetPolicy::Never,
211                provenance: candidate.provenance.clone(),
212            },
213        );
214    }
215    let _ = fields; // I3 authored order is already retained by each I6 Rule.
216    SubmissionProducts { candidates, plans }
217}
218
219fn lower_candidate(
220    fact: &AuthoredSubmissionDeclarationFact,
221    forms: &BTreeMap<FormId, FormEntity>,
222    action_batches: &EffectTriggerPlan,
223) -> SubmissionDeclarationCandidate {
224    let mut violations = Vec::new();
225    if fact.owner_component.is_none() {
226        violations.push(SubmissionDeclarationViolation::InvalidOwner);
227    }
228    if !fact.submit_invoked {
229        violations.push(SubmissionDeclarationViolation::InvalidDecoratorInvocation);
230    }
231    if fact.submit_argument_count != 1 {
232        violations.push(SubmissionDeclarationViolation::InvalidDecoratorArity);
233    }
234    if fact.form_designator.is_none() {
235        violations.push(SubmissionDeclarationViolation::InvalidFormDesignator);
236    }
237    if !fact.has_action || !fact.action_invoked || fact.action_argument_count != 0 {
238        violations.push(SubmissionDeclarationViolation::InvalidAction);
239    }
240    if fact.is_static {
241        violations.push(SubmissionDeclarationViolation::StaticMethod);
242    }
243    if fact.is_async {
244        violations.push(SubmissionDeclarationViolation::AsyncMethod);
245    }
246    if fact.parameter_count != 0 {
247        violations.push(SubmissionDeclarationViolation::ParameterizedMethod);
248    }
249    if fact.return_type.as_deref() != Some("void") {
250        violations.push(SubmissionDeclarationViolation::InvalidReturnType);
251    }
252    if fact.inherited {
253        violations.push(SubmissionDeclarationViolation::InheritedDeclaration);
254    }
255    let resolved_form = fact
256        .owner_component
257        .as_ref()
258        .zip(fact.form_designator.as_ref())
259        .and_then(|(component, name)| {
260            forms
261                .values()
262                .find(|form| form.owner.entity_id() == Some(component) && form.name == *name)
263                .map(|form| form.id.clone())
264        });
265    if fact.submit_invoked
266        && fact.submit_argument_count == 1
267        && fact.form_designator.is_some()
268        && resolved_form.is_none()
269    {
270        violations.push(SubmissionDeclarationViolation::UnresolvedForm);
271    }
272    let action_batch = fact
273        .owner_component
274        .as_ref()
275        .zip(fact.method_name.as_ref())
276        .map(|(component, method)| component.action_batch(method))
277        .filter(|id| action_batches.action_batches.contains_key(id));
278    if fact.has_action && action_batch.is_none() {
279        violations.push(SubmissionDeclarationViolation::InvalidAction);
280    }
281    violations.sort();
282    violations.dedup();
283    SubmissionDeclarationCandidate {
284        id: fact.id.clone(),
285        owner_component: fact.owner_component.clone(),
286        method: fact.method.clone(),
287        form_designator: fact.form_designator.clone(),
288        resolved_form,
289        action_batch,
290        provenance: fact.method_provenance.clone(),
291        decorator_provenance: fact.decorator_provenance.clone(),
292        form_designator_provenance: fact.form_designator_provenance.clone(),
293        violations,
294    }
295}
296
297fn add_violation(
298    candidate: &mut SubmissionDeclarationCandidate,
299    violation: SubmissionDeclarationViolation,
300) {
301    if !candidate.violations.contains(&violation) {
302        candidate.violations.push(violation);
303        candidate.violations.sort();
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use crate::{
310        build_application_semantic_model, FormId, SubmissionDeclarationViolation, SubmissionPlanId,
311    };
312
313    #[test]
314    fn lowers_one_submit_plan_with_the_complete_form_rule_order() {
315        let parsed = presolve_parser::parse_file(
316            "src/Profile.tsx",
317            r#"
318@component("profile-editor")
319class ProfileEditor {
320  @form() profile!: Form;
321  @validate(required()) @field("profile") name = "";
322  @validate(min(0)) @field("profile") age = 0;
323  @action() @submit("profile") save(): void {}
324  render() { return <input field={this.name} />; }
325}
326"#,
327        );
328        let model = build_application_semantic_model(&parsed);
329        let form = FormId::for_owner(&model.components[0].id, "profile");
330        let plan = model.submissions.plan(&form).expect("submission plan");
331        assert_eq!(plan.id, SubmissionPlanId::for_form(&form));
332        assert_eq!(plan.validation_rules.len(), 2);
333        assert_eq!(
334            plan.action_batch,
335            model.components[0].id.action_batch("save")
336        );
337        assert!(matches!(plan.reset_policy, super::SubmitResetPolicy::Never));
338    }
339
340    #[test]
341    fn retains_invalid_submit_candidates_without_plan_or_winner() {
342        let parsed = presolve_parser::parse_file(
343            "src/Profile.tsx",
344            r#"
345@component("profile-editor")
346class ProfileEditor {
347  @form() profile!: Form;
348  @field(this.profile) name = "";
349  @submit(this.profile) missingAction(): void {}
350  @action() @submit(this.profile) duplicate(value: string): void {}
351  render() { return <input field={this.name} />; }
352}
353"#,
354        );
355        let model = build_application_semantic_model(&parsed);
356        assert_eq!(model.submissions.candidates.len(), 2);
357        assert!(model.submissions.plans.is_empty());
358        assert!(model
359            .submissions
360            .candidates
361            .iter()
362            .any(|candidate| candidate
363                .violations
364                .contains(&SubmissionDeclarationViolation::InvalidAction)));
365        assert!(model
366            .submissions
367            .candidates
368            .iter()
369            .any(|candidate| candidate
370                .violations
371                .contains(&SubmissionDeclarationViolation::ParameterizedMethod)));
372    }
373}