Skip to main content

presolve_parser/
lib.rs

1pub mod model;
2mod oxc_adapter;
3
4pub use model::{
5    ParseDiagnostic, ParseLabel, ParseSeverity, ParsedArithmeticExpression,
6    ParsedArithmeticExpressionKind, ParsedArithmeticOperator, ParsedCallArgument,
7    ParsedCallExpression, ParsedClass, ParsedClassHeritage, ParsedComparisonOperator,
8    ParsedComputedExpression, ParsedComputedExpressionKind, ParsedConstantExpression,
9    ParsedConstantExpressionKind, ParsedDecorator, ParsedEffectBody, ParsedEffectExpression,
10    ParsedEffectExpressionKind, ParsedEffectStatement, ParsedEffectStatementKind,
11    ParsedEventHandler, ParsedExport, ParsedExportKind, ParsedExportSpecifier, ParsedFile,
12    ParsedImport, ParsedImportSpecifier, ParsedInlineHandler, ParsedJsxAttribute,
13    ParsedJsxAttributeValue, ParsedJsxChild, ParsedJsxConditional, ParsedJsxElement,
14    ParsedJsxFragment, ParsedJsxList, ParsedJsxNode, ParsedLocalVariable, ParsedLogicalOperator,
15    ParsedMethod, ParsedMethodCall, ParsedMethodParameter, ParsedProperty, ParsedSerializableValue,
16    ParsedSourceAst, ParsedStateOperation, ParsedStateUpdate, ParsedStaticMemberDesignator,
17    ParsedThisMemberDesignator, ParsedTypeAlias, ParsedTypeAnnotation, ParsedUnaryOperator,
18    ParsedUnsupportedEffectStatementKind, ParsedValidationRuleArgument,
19    ParsedValidationRuleArgumentKind, ParsedValidationRuleExpression,
20    ParsedValidationRuleExpressionKind, SourceSpan,
21};
22pub use oxc_adapter::parse_file;
23
24/// Validates one flags-free ECMAScript pattern using the compiler frontend's
25/// pinned ECMAScript grammar authority.
26#[must_use]
27pub fn is_valid_ecmascript_pattern(pattern: &str) -> bool {
28    let allocator = oxc_allocator::Allocator::default();
29    oxc_regular_expression::LiteralParser::new(
30        &allocator,
31        pattern,
32        None,
33        oxc_regular_expression::Options {
34            pattern_span_offset: 0,
35            flags_span_offset: 0,
36        },
37    )
38    .parse()
39    .is_ok()
40}
41
42#[cfg(test)]
43mod tests {
44    use super::{
45        parse_file, ParsedEffectStatementKind, ParsedJsxAttributeValue, ParsedSerializableValue,
46        ParsedUnsupportedEffectStatementKind, ParsedValidationRuleArgumentKind,
47        ParsedValidationRuleExpressionKind,
48    };
49
50    #[test]
51    fn retains_validation_rule_calls_constants_and_direct_field_designators() {
52        let source = r#"
53@component("profile")
54class Profile {
55  @form() profile!: Form;
56
57  @validate(min(1 + 2))
58  @validate(pattern("^[a-z]+$"))
59  @field(this.profile)
60  name = "";
61
62  @validate(equals(this.name))
63  @field(this.profile)
64  confirmation = "";
65}
66"#;
67        let parsed = parse_file("src/Profile.tsx", source);
68        let name = &parsed.classes[0].properties[1];
69        let validations = name
70            .decorators
71            .iter()
72            .filter(|decorator| decorator.name == "validate")
73            .collect::<Vec<_>>();
74        assert_eq!(validations.len(), 2);
75        let expression = validations[0]
76            .validation_rule_expression
77            .as_ref()
78            .expect("rule expression");
79        let ParsedValidationRuleExpressionKind::Call { callee, arguments } = &expression.kind
80        else {
81            panic!("expected validation call");
82        };
83        assert_eq!(callee.as_deref(), Some("min"));
84        assert!(matches!(
85            arguments[0].kind,
86            ParsedValidationRuleArgumentKind::Constant(_)
87        ));
88        let equals = parsed.classes[0].properties[2]
89            .decorators
90            .iter()
91            .find(|decorator| decorator.name == "validate")
92            .unwrap()
93            .validation_rule_expression
94            .as_ref()
95            .unwrap();
96        let ParsedValidationRuleExpressionKind::Call { arguments, .. } = &equals.kind else {
97            panic!("expected equals call");
98        };
99        assert!(matches!(
100            &arguments[0].kind,
101            ParsedValidationRuleArgumentKind::ThisMember(designator)
102                if designator.member == "name"
103        ));
104    }
105
106    #[test]
107    fn retains_a_source_faithful_general_estree_product() {
108        let source = r#"
109import type { CardProps } from "./types";
110export const Card = <section aria-label="card">{1 + 2}</section>;
111"#;
112        let parsed = parse_file("src/Card.tsx", source);
113        assert_eq!(parsed.syntax.source, source);
114        assert_eq!(parsed.syntax.span.start, 0);
115        assert_eq!(parsed.syntax.span.end, source.len());
116        assert!(parsed.syntax.estree_json.contains("\"Program\""));
117        assert!(parsed.syntax.estree_json.contains("\"ImportDeclaration\""));
118        assert!(parsed.syntax.estree_json.contains("\"JSXElement\""));
119        assert!(
120            parsed.syntax.estree_json.contains("\"TSImportType\"")
121                || parsed
122                    .syntax
123                    .estree_json
124                    .contains("\"importKind\":\"type\"")
125        );
126    }
127
128    #[test]
129    fn retains_general_call_sites_without_framework_classification() {
130        let source = r#"
131const name = environment.public("PRESOLVE_PUBLIC_APP_NAME");
132const dynamic = environment.public(getName());
133"#;
134        let parsed = parse_file("src/environment.ts", source);
135        assert_eq!(parsed.call_expressions.len(), 3);
136        let public_calls = parsed
137            .call_expressions
138            .iter()
139            .filter(|call| {
140                &source[call.callee_span.start..call.callee_span.end] == "environment.public"
141            })
142            .collect::<Vec<_>>();
143        assert_eq!(public_calls.len(), 2);
144        assert!(public_calls.iter().all(|call| {
145            call.member_object_span
146                .is_some_and(|span| &source[span.start..span.end] == "environment")
147                && call
148                    .member_property_span
149                    .is_some_and(|span| &source[span.start..span.end] == "public")
150        }));
151        assert!(matches!(
152            &public_calls[0].arguments[0],
153            super::ParsedCallArgument::StringLiteral { value, .. }
154                if value == "PRESOLVE_PUBLIC_APP_NAME"
155        ));
156        assert!(matches!(
157            public_calls[1].arguments[0],
158            super::ParsedCallArgument::Other { .. }
159        ));
160    }
161
162    #[test]
163    fn retains_invalid_outer_validation_invocation_and_expression_shapes() {
164        let source = r#"
165@component("profile")
166class Profile {
167  @validate
168  first = "";
169  @validate()
170  second = "";
171  @validate(required(), email())
172  third = "";
173  @validate(schema.required())
174  fourth = "";
175}
176"#;
177        let parsed = parse_file("src/Profile.tsx", source);
178        let decorators = parsed.classes[0]
179            .properties
180            .iter()
181            .map(|property| &property.decorators[0])
182            .collect::<Vec<_>>();
183        assert!(!decorators[0].is_invoked);
184        assert_eq!(decorators[1].argument_count, 0);
185        assert_eq!(decorators[2].argument_count, 2);
186        assert!(matches!(
187            decorators[3]
188                .validation_rule_expression
189                .as_ref()
190                .unwrap()
191                .kind,
192            ParsedValidationRuleExpressionKind::Call { callee: None, .. }
193        ));
194    }
195
196    #[test]
197    fn retains_source_faithful_class_heritage() {
198        let source = "@component(\"x-child\") class Child extends Base.Component {}";
199        let parsed = parse_file("src/Child.tsx", source);
200        let heritage = parsed.classes[0].heritage.as_ref().expect("heritage");
201        assert_eq!(heritage.base, "Base.Component");
202        assert_eq!(
203            &source[heritage.span.start..heritage.span.end],
204            "Base.Component"
205        );
206    }
207
208    #[test]
209    fn retains_source_faithful_import_binding_spans() {
210        let source = "import { Component as FrameworkBase } from \"presolve\";";
211        let parsed = parse_file("src/Card.tsx", source);
212        let specifier = &parsed.imports[0].specifiers[0];
213        assert_eq!(specifier.imported, "Component");
214        assert_eq!(specifier.local, "FrameworkBase");
215        assert_eq!(
216            &source[specifier.local_span.start..specifier.local_span.end],
217            "FrameworkBase"
218        );
219    }
220
221    #[test]
222    fn retains_direct_initializer_call_spans_without_intrinsic_classification() {
223        let source = "class Counter { count = reactiveCell(0); plain = 1; }";
224        let parsed = parse_file("src/Counter.tsx", source);
225        let property = &parsed.classes[0].properties[0];
226        let call = property
227            .initializer_call
228            .as_ref()
229            .expect("direct calls are retained as syntax facts");
230        assert_eq!(
231            &source[call.callee_span.start..call.callee_span.end],
232            "reactiveCell"
233        );
234        assert_eq!(&source[call.span.start..call.span.end], "reactiveCell(0)");
235        assert_eq!(call.argument_count, 1);
236        assert!(parsed.classes[0].properties[1].initializer_call.is_none());
237    }
238
239    #[test]
240    fn retains_inline_initializer_handler_updates_without_classifying_the_call() {
241        let source = r#"
242class Counter {
243  increment = activate(() => { this.count += 1; unrelated(); });
244  reset = activate(async function () { this.count = 0; });
245}
246"#;
247        let parsed = parse_file("src/Counter.tsx", source);
248        let increment = parsed.classes[0].properties[0]
249            .initializer_call
250            .as_ref()
251            .and_then(|call| call.inline_handler.as_ref())
252            .expect("inline arrow handler should remain a syntax fact");
253        assert!(!increment.is_async);
254        assert!(!increment.is_expression_body);
255        assert_eq!(increment.state_updates.len(), 1);
256        assert_eq!(increment.state_updates[0].field, "count");
257        assert_eq!(increment.unsupported_statement_spans.len(), 1);
258        assert_eq!(
259            increment
260                .effect_body
261                .as_ref()
262                .expect("inline block body")
263                .statements
264                .len(),
265            2
266        );
267        assert_eq!(
268            &source[increment.unsupported_statement_spans[0].start
269                ..increment.unsupported_statement_spans[0].end],
270            "unrelated();"
271        );
272
273        let reset = parsed.classes[0].properties[1]
274            .initializer_call
275            .as_ref()
276            .and_then(|call| call.inline_handler.as_ref())
277            .expect("inline function handler should remain a syntax fact");
278        assert!(reset.is_async);
279        assert_eq!(reset.state_updates.len(), 1);
280        assert!(reset.unsupported_statement_spans.is_empty());
281        assert!(reset.effect_body.is_some());
282    }
283
284    #[test]
285    fn retains_inline_effect_cleanup_without_recognizing_the_callee() {
286        let source = r#"
287class Counter {
288  sync = observe(() => {
289    document.title = this.title;
290    return () => { document.title = ""; };
291  });
292}
293"#;
294        let parsed = parse_file("src/Counter.tsx", source);
295        let body = parsed.classes[0].properties[0]
296            .initializer_call
297            .as_ref()
298            .and_then(|call| call.inline_handler.as_ref())
299            .and_then(|handler| handler.effect_body.as_ref())
300            .expect("general inline block body");
301        assert_eq!(body.statements.len(), 1);
302        let cleanup = body.cleanup.as_ref().expect("cleanup callback");
303        assert!(!cleanup.is_async);
304        assert_eq!(cleanup.body.statements.len(), 1);
305    }
306
307    #[test]
308    fn retains_decorated_context_field_declaration_facts() {
309        let source = r#"
310@component("x-app-shell")
311class AppShell extends Component {
312  @context()
313  locale: string = "en";
314}
315"#;
316        let parsed = parse_file("src/AppShell.tsx", source);
317        let property = &parsed.classes[0].properties[0];
318
319        assert_eq!(property.name, "locale");
320        assert_eq!(property.decorators[0].name, "context");
321        assert_eq!(property.decorators[0].argument_count, 0);
322        assert_eq!(property.type_annotation.as_ref().unwrap().text, "string");
323        assert!(property.initializer_literal.is_some());
324        assert!(!property.is_static);
325        assert_eq!(property.span.start, source.find("@context()").unwrap());
326        assert!(property.span.end > property.initializer_span.unwrap().end);
327    }
328
329    #[test]
330    fn retains_slot_declaration_and_invalid_declaration_form_facts() {
331        let source = r#"
332@component("x-card")
333class Card extends Component {
334  @slot()
335  children!: SlotContent;
336
337  @slot("header")
338  static invalid: string = "bad";
339
340  @slot()
341  outlet() {}
342
343  attach(@slot() content: SlotContent) {}
344}
345"#;
346        let parsed = parse_file("src/Card.tsx", source);
347        assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics);
348        let class = &parsed.classes[0];
349        let children = &class.properties[0];
350        assert_eq!(children.decorators[0].name, "slot");
351        assert_eq!(children.decorators[0].argument_count, 0);
352        assert_eq!(
353            children.type_annotation.as_ref().unwrap().text,
354            "SlotContent"
355        );
356        assert!(children.is_definite_assignment);
357        assert!(children.initializer.is_none());
358
359        let invalid = &class.properties[1];
360        assert_eq!(invalid.decorators[0].argument_count, 1);
361        assert!(invalid.is_static);
362        assert_eq!(invalid.initializer.as_deref(), Some("\"bad\""));
363
364        assert_eq!(class.methods[0].decorators[0].name, "slot");
365        assert_eq!(class.methods[1].parameters[0].decorators[0].name, "slot");
366    }
367
368    #[test]
369    fn retains_normalized_form_declaration_targets_and_invocation_facts() {
370        let source = r#"
371@form()
372class NotAFormField {}
373
374@component("x-profile")
375class Profile extends Component {
376  @form()
377  profile!: Form;
378
379  @form()
380  declare settings: Form;
381
382  @form
383  bare!: Form;
384
385  @form("named")
386  named!: Form;
387
388  @form()
389  @state
390  conflicting!: Form;
391
392  @form()
393  get current(): Form { return this.profile; }
394
395  @form()
396  set current(value: Form) {}
397
398  parameter(@form() value: Form) {}
399}
400"#;
401        let parsed = parse_file("src/Profile.tsx", source);
402
403        assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics);
404        assert!(parsed.classes[0].decorators[0].is_invoked);
405        let profile = &parsed.classes[1];
406        assert!(profile.properties[0].is_identifier_name);
407        assert!(profile.properties[0].is_definite_assignment);
408        assert!(!profile.properties[0].is_declare);
409        assert!(profile.properties[1].is_declare);
410        assert!(!profile.properties[2].decorators[0].is_invoked);
411        assert_eq!(profile.properties[2].decorators[0].argument_count, 0);
412        assert!(profile.properties[3].decorators[0].is_invoked);
413        assert_eq!(profile.properties[3].decorators[0].argument_count, 1);
414        assert_eq!(profile.properties[3].decorators[0].argument_spans.len(), 1);
415        let named_argument = profile.properties[3].decorators[0].argument_spans[0];
416        assert_eq!(
417            &source[named_argument.start..named_argument.end],
418            "\"named\""
419        );
420        assert_eq!(profile.properties[4].decorators.len(), 2);
421        assert_eq!(profile.properties[4].decorators[1].name, "state");
422        assert!(!profile.properties[4].decorators[1].is_invoked);
423        assert!(profile.methods[0].is_getter);
424        assert!(!profile.methods[0].is_setter);
425        assert!(profile.methods[1].is_setter);
426        assert_eq!(profile.methods[2].parameters[0].decorators[0].name, "form");
427        assert_eq!(
428            profile.properties[0].span.start,
429            source.find("@form()\n  profile").unwrap()
430        );
431        assert!(parsed.local_type_bindings.contains(&"Profile".to_string()));
432    }
433
434    #[test]
435    fn retains_module_local_type_namespace_bindings() {
436        let parsed = parse_file(
437            "src/types.ts",
438            r#"
439class ClassType {}
440type AliasType = string;
441interface InterfaceType {}
442enum EnumType { Value }
443import ImportEqualsType = require("./type");
444"#,
445        );
446
447        assert_eq!(
448            parsed.local_type_bindings,
449            [
450                "AliasType",
451                "ClassType",
452                "EnumType",
453                "ImportEqualsType",
454                "InterfaceType",
455            ]
456        );
457    }
458
459    #[test]
460    fn retains_normalized_form_field_designators_targets_values_and_provenance() {
461        let source = r#"
462@field(this.profileForm)
463class InvalidTarget {}
464
465@component("profile-editor")
466class ProfileEditor {
467  @form() profileForm!: Form;
468  @field(this.profileForm) displayName = "Austin";
469  @field(this.profileForm) address = { city: "", postalCode: "" };
470  @field
471  bare = "";
472  @field("profileForm") stringDesignator = "";
473  @field(this.forms.profile) chained = "";
474  @field(this.profileForm) ["computed"] = "";
475  @field(this.profileForm) #privateName = "";
476  @field(this.profileForm) method() {}
477  parameter(@field(this.profileForm) value: string) {}
478}
479"#;
480        let parsed = parse_file("src/ProfileEditor.tsx", source);
481        let editor = &parsed.classes[1];
482        let display = &editor.properties[1];
483
484        assert_eq!(display.decorators[0].argument_count, 1);
485        assert_eq!(
486            display.decorators[0]
487                .this_member_argument
488                .as_ref()
489                .map(|designator| designator.member.as_str()),
490            Some("profileForm")
491        );
492        assert_eq!(
493            display.initializer_literal,
494            Some(ParsedSerializableValue::String("Austin".to_string()))
495        );
496        assert!(matches!(
497            editor.properties[2].initializer_literal,
498            Some(ParsedSerializableValue::Object(_))
499        ));
500        assert!(!editor.properties[3].decorators[0].is_invoked);
501        assert!(editor.properties[4].decorators[0]
502            .this_member_argument
503            .is_none());
504        assert_eq!(
505            editor.properties[4].decorators[0].argument.as_deref(),
506            Some("profileForm")
507        );
508        assert!(editor.properties[5].decorators[0]
509            .this_member_argument
510            .is_none());
511        assert!(!editor.properties[6].is_identifier_name);
512        assert!(!editor.properties[7].is_identifier_name);
513        assert_eq!(editor.methods[0].decorators[0].name, "field");
514        assert_eq!(editor.methods[1].parameters[0].decorators[0].name, "field");
515        assert_eq!(
516            display.span.start,
517            source.find("@field(this.profileForm) displayName").unwrap()
518        );
519    }
520
521    #[test]
522    fn retains_every_static_decorator_argument_without_admitting_its_semantics() {
523        let parsed = parse_file(
524            "src/NestedField.tsx",
525            r#"@component("x-nested") class NestedField {
526  @field("profile", "address.street") street = "";
527  render() { return <div />; }
528}"#,
529        );
530        let decorator = &parsed.classes[0].properties[0].decorators[0];
531        assert_eq!(decorator.name, "field");
532        assert_eq!(decorator.argument_count, 2);
533        assert_eq!(
534            decorator.arguments,
535            vec![
536                Some("profile".to_string()),
537                Some("address.street".to_string())
538            ]
539        );
540        assert_eq!(decorator.argument, Some("profile".to_string()));
541    }
542
543    #[test]
544    fn retains_normalized_form_control_attribute_facts() {
545        let source = r#"
546@component("profile-editor")
547class ProfileEditor {
548  render() {
549    return <main>
550      <input type="radio" value="email" field={this.contact} />
551      <input type={this.kind} field={this.contact} {...props} />
552      <input field={this["contact"]} />
553      <select multiple={this.multiple} field={this.tags} />
554    </main>;
555  }
556}
557"#;
558        let parsed = parse_file("src/ProfileEditor.tsx", source);
559        let super::ParsedJsxNode::Element(root) = &parsed.classes[0].methods[0].jsx_roots[0] else {
560            panic!("element root");
561        };
562        let elements = root
563            .children
564            .iter()
565            .filter_map(|child| match child {
566                super::ParsedJsxChild::Element(element) => Some(element),
567                _ => None,
568            })
569            .collect::<Vec<_>>();
570
571        let radio = &elements[0];
572        let value = radio
573            .attributes
574            .iter()
575            .find(|attribute| attribute.name == "value")
576            .expect("radio value");
577        let field = radio
578            .attributes
579            .iter()
580            .find(|attribute| attribute.name == "field")
581            .expect("field binding");
582        assert_eq!(
583            value.constant_value,
584            Some(ParsedSerializableValue::String("email".to_string()))
585        );
586        assert_eq!(
587            field
588                .this_member
589                .as_ref()
590                .map(|member| member.member.as_str()),
591            Some("contact")
592        );
593        assert_eq!(
594            &source[field.expression_span.unwrap().start..field.expression_span.unwrap().end],
595            "this.contact"
596        );
597        assert!(elements[1]
598            .attributes
599            .iter()
600            .any(|attribute| matches!(attribute.value, ParsedJsxAttributeValue::Spread(_))));
601        assert!(elements[2].attributes[0].this_member.is_none());
602        assert!(elements[3]
603            .attributes
604            .iter()
605            .find(|attribute| attribute.name == "multiple")
606            .expect("multiple")
607            .this_member
608            .is_some());
609    }
610
611    #[test]
612    fn retains_canonical_component_tag_names_and_exact_name_spans() {
613        let source = r#"
614@component("x-page")
615class Page extends Component {
616  render() { return <main><Card /><Registry.Card /></main>; }
617}
618"#;
619        let parsed = parse_file("src/Page.tsx", source);
620        let super::ParsedJsxNode::Element(root) = &parsed.classes[0].methods[0].jsx_roots[0] else {
621            panic!("element root");
622        };
623        let elements = root
624            .children
625            .iter()
626            .filter_map(|child| match child {
627                super::ParsedJsxChild::Element(element) => Some(element),
628                _ => None,
629            })
630            .collect::<Vec<_>>();
631
632        assert_eq!(elements[0].name, "Card");
633        assert_eq!(elements[1].name, "Registry.Card");
634        assert_eq!(
635            &source[elements[0].name_span.start..elements[0].name_span.end],
636            "Card"
637        );
638        assert_eq!(
639            &source[elements[1].name_span.start..elements[1].name_span.end],
640            "Registry.Card"
641        );
642    }
643
644    #[test]
645    fn retains_static_member_provider_designators_and_value_expressions() {
646        let parsed = parse_file(
647            "src/AppShell.tsx",
648            r#"
649@component("x-app-shell")
650class AppShell extends Component {
651  @provide(AppShell.theme)
652  providedTheme: string = this.theme ?? "light";
653}
654"#,
655        );
656        let property = &parsed.classes[0].properties[0];
657        let decorator = &property.decorators[0];
658        let designator = decorator.static_member_argument.as_ref().unwrap();
659
660        assert_eq!(decorator.name, "provide");
661        assert_eq!(decorator.argument_count, 1);
662        assert_eq!(designator.object, "AppShell");
663        assert_eq!(designator.member, "theme");
664        assert!(property.initializer_expression.is_some());
665    }
666
667    #[test]
668    fn retains_ordered_effect_statement_syntax_and_unsupported_forms() {
669        let parsed = parse_file(
670            "src/Effects.tsx",
671            r#"
672@component("x-effects")
673class Effects extends Component {
674  @effect()
675  sync() {
676    document.title = this.title;
677    analytics.track("view", this.total + this.tax);
678    return;
679  }
680
681  @effect()
682  invalid() {
683    const title = this.title;
684    if (this.enabled) { analytics.track("enabled"); }
685  }
686}
687"#,
688        );
689        let methods = &parsed.classes[0].methods;
690        let body = methods[0].effect_body.as_ref().expect("effect body");
691        assert_eq!(body.statements.len(), 3);
692        assert!(matches!(
693            body.statements[0].kind,
694            ParsedEffectStatementKind::StaticMemberAssignment { .. }
695        ));
696        assert!(matches!(
697            body.statements[1].kind,
698            ParsedEffectStatementKind::CapabilityCall { .. }
699        ));
700        assert!(matches!(
701            body.statements[2].kind,
702            ParsedEffectStatementKind::EffectReturn { value: None }
703        ));
704
705        let invalid = methods[1].effect_body.as_ref().expect("effect body");
706        assert!(matches!(
707            invalid.statements[0].kind,
708            ParsedEffectStatementKind::Unsupported(
709                ParsedUnsupportedEffectStatementKind::LocalDeclaration
710            )
711        ));
712        assert!(matches!(
713            invalid.statements[1].kind,
714            ParsedEffectStatementKind::Unsupported(ParsedUnsupportedEffectStatementKind::Branch)
715        ));
716    }
717
718    #[test]
719    fn retains_submit_decorator_designator_and_method_signature_facts() {
720        let parsed = parse_file(
721            "src/Profile.tsx",
722            r#"
723@component("profile")
724class Profile {
725  @action() @submit(this.profileForm) save(): void {}
726  @submit invalid(value: string): string { return ""; }
727  @action() @submit(this.profileForm) static saveStatic(): void {}
728}
729"#,
730        );
731        let methods = &parsed.classes[0].methods;
732        let submit = methods[0]
733            .decorators
734            .iter()
735            .find(|decorator| decorator.name == "submit")
736            .expect("submit decorator");
737        assert!(submit.is_invoked);
738        assert_eq!(submit.argument_count, 1);
739        assert_eq!(
740            submit
741                .this_member_argument
742                .as_ref()
743                .map(|value| value.member.as_str()),
744            Some("profileForm")
745        );
746        assert_eq!(
747            methods[0]
748                .return_type_annotation
749                .as_ref()
750                .map(|annotation| annotation.text.as_str()),
751            Some("void")
752        );
753        assert_eq!(methods[1].parameters.len(), 1);
754        assert!(
755            !methods[1]
756                .decorators
757                .iter()
758                .find(|decorator| decorator.name == "submit")
759                .expect("bare submit")
760                .is_invoked
761        );
762        assert!(methods[2].is_static);
763    }
764}