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