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, ParsedClass, ParsedClassHeritage,
7    ParsedComparisonOperator, ParsedComputedExpression, ParsedComputedExpressionKind,
8    ParsedConstantExpression, ParsedConstantExpressionKind, ParsedDecorator, ParsedEffectBody,
9    ParsedEffectExpression, ParsedEffectExpressionKind, ParsedEffectStatement,
10    ParsedEffectStatementKind, ParsedEventHandler, ParsedExport, ParsedExportKind,
11    ParsedExportSpecifier, ParsedFile, ParsedImport, ParsedImportSpecifier, ParsedJsxAttribute,
12    ParsedJsxAttributeValue, ParsedJsxChild, ParsedJsxConditional, ParsedJsxElement,
13    ParsedJsxFragment, ParsedJsxList, ParsedJsxNode, ParsedLocalVariable, ParsedLogicalOperator,
14    ParsedMethod, ParsedMethodCall, ParsedMethodParameter, ParsedProperty, ParsedSerializableValue,
15    ParsedStateOperation, ParsedStateUpdate, ParsedStaticMemberDesignator,
16    ParsedThisMemberDesignator, ParsedTypeAlias, ParsedTypeAnnotation, ParsedUnaryOperator,
17    ParsedUnsupportedEffectStatementKind, ParsedValidationRuleArgument,
18    ParsedValidationRuleArgumentKind, ParsedValidationRuleExpression,
19    ParsedValidationRuleExpressionKind, SourceSpan,
20};
21pub use oxc_adapter::parse_file;
22
23/// Validates one flags-free ECMAScript pattern using the compiler frontend's
24/// pinned ECMAScript grammar authority.
25#[must_use]
26pub fn is_valid_ecmascript_pattern(pattern: &str) -> bool {
27    let allocator = oxc_allocator::Allocator::default();
28    oxc_regular_expression::LiteralParser::new(
29        &allocator,
30        pattern,
31        None,
32        oxc_regular_expression::Options {
33            pattern_span_offset: 0,
34            flags_span_offset: 0,
35        },
36    )
37    .parse()
38    .is_ok()
39}
40
41#[cfg(test)]
42mod tests {
43    use super::{
44        parse_file, ParsedEffectStatementKind, ParsedJsxAttributeValue, ParsedSerializableValue,
45        ParsedUnsupportedEffectStatementKind, ParsedValidationRuleArgumentKind,
46        ParsedValidationRuleExpressionKind,
47    };
48
49    #[test]
50    fn retains_validation_rule_calls_constants_and_direct_field_designators() {
51        let source = r#"
52@component("profile")
53class Profile {
54  @form() profile!: Form;
55
56  @validate(min(1 + 2))
57  @validate(pattern("^[a-z]+$"))
58  @field(this.profile)
59  name = "";
60
61  @validate(equals(this.name))
62  @field(this.profile)
63  confirmation = "";
64}
65"#;
66        let parsed = parse_file("src/Profile.tsx", source);
67        let name = &parsed.classes[0].properties[1];
68        let validations = name
69            .decorators
70            .iter()
71            .filter(|decorator| decorator.name == "validate")
72            .collect::<Vec<_>>();
73        assert_eq!(validations.len(), 2);
74        let expression = validations[0]
75            .validation_rule_expression
76            .as_ref()
77            .expect("rule expression");
78        let ParsedValidationRuleExpressionKind::Call { callee, arguments } = &expression.kind
79        else {
80            panic!("expected validation call");
81        };
82        assert_eq!(callee.as_deref(), Some("min"));
83        assert!(matches!(
84            arguments[0].kind,
85            ParsedValidationRuleArgumentKind::Constant(_)
86        ));
87        let equals = parsed.classes[0].properties[2]
88            .decorators
89            .iter()
90            .find(|decorator| decorator.name == "validate")
91            .unwrap()
92            .validation_rule_expression
93            .as_ref()
94            .unwrap();
95        let ParsedValidationRuleExpressionKind::Call { arguments, .. } = &equals.kind else {
96            panic!("expected equals call");
97        };
98        assert!(matches!(
99            &arguments[0].kind,
100            ParsedValidationRuleArgumentKind::ThisMember(designator)
101                if designator.member == "name"
102        ));
103    }
104
105    #[test]
106    fn retains_invalid_outer_validation_invocation_and_expression_shapes() {
107        let source = r#"
108@component("profile")
109class Profile {
110  @validate
111  first = "";
112  @validate()
113  second = "";
114  @validate(required(), email())
115  third = "";
116  @validate(schema.required())
117  fourth = "";
118}
119"#;
120        let parsed = parse_file("src/Profile.tsx", source);
121        let decorators = parsed.classes[0]
122            .properties
123            .iter()
124            .map(|property| &property.decorators[0])
125            .collect::<Vec<_>>();
126        assert!(!decorators[0].is_invoked);
127        assert_eq!(decorators[1].argument_count, 0);
128        assert_eq!(decorators[2].argument_count, 2);
129        assert!(matches!(
130            decorators[3]
131                .validation_rule_expression
132                .as_ref()
133                .unwrap()
134                .kind,
135            ParsedValidationRuleExpressionKind::Call { callee: None, .. }
136        ));
137    }
138
139    #[test]
140    fn retains_source_faithful_class_heritage() {
141        let source = "@component(\"x-child\") class Child extends Base.Component {}";
142        let parsed = parse_file("src/Child.tsx", source);
143        let heritage = parsed.classes[0].heritage.as_ref().expect("heritage");
144        assert_eq!(heritage.base, "Base.Component");
145        assert_eq!(
146            &source[heritage.span.start..heritage.span.end],
147            "Base.Component"
148        );
149    }
150
151    #[test]
152    fn retains_decorated_context_field_declaration_facts() {
153        let source = r#"
154@component("x-app-shell")
155class AppShell extends Component {
156  @context()
157  locale: string = "en";
158}
159"#;
160        let parsed = parse_file("src/AppShell.tsx", source);
161        let property = &parsed.classes[0].properties[0];
162
163        assert_eq!(property.name, "locale");
164        assert_eq!(property.decorators[0].name, "context");
165        assert_eq!(property.decorators[0].argument_count, 0);
166        assert_eq!(property.type_annotation.as_ref().unwrap().text, "string");
167        assert!(property.initializer_literal.is_some());
168        assert!(!property.is_static);
169        assert_eq!(property.span.start, source.find("@context()").unwrap());
170        assert!(property.span.end > property.initializer_span.unwrap().end);
171    }
172
173    #[test]
174    fn retains_slot_declaration_and_invalid_declaration_form_facts() {
175        let source = r#"
176@component("x-card")
177class Card extends Component {
178  @slot()
179  children!: SlotContent;
180
181  @slot("header")
182  static invalid: string = "bad";
183
184  @slot()
185  outlet() {}
186
187  attach(@slot() content: SlotContent) {}
188}
189"#;
190        let parsed = parse_file("src/Card.tsx", source);
191        assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics);
192        let class = &parsed.classes[0];
193        let children = &class.properties[0];
194        assert_eq!(children.decorators[0].name, "slot");
195        assert_eq!(children.decorators[0].argument_count, 0);
196        assert_eq!(
197            children.type_annotation.as_ref().unwrap().text,
198            "SlotContent"
199        );
200        assert!(children.is_definite_assignment);
201        assert!(children.initializer.is_none());
202
203        let invalid = &class.properties[1];
204        assert_eq!(invalid.decorators[0].argument_count, 1);
205        assert!(invalid.is_static);
206        assert_eq!(invalid.initializer.as_deref(), Some("\"bad\""));
207
208        assert_eq!(class.methods[0].decorators[0].name, "slot");
209        assert_eq!(class.methods[1].parameters[0].decorators[0].name, "slot");
210    }
211
212    #[test]
213    fn retains_normalized_form_declaration_targets_and_invocation_facts() {
214        let source = r#"
215@form()
216class NotAFormField {}
217
218@component("x-profile")
219class Profile extends Component {
220  @form()
221  profile!: Form;
222
223  @form()
224  declare settings: Form;
225
226  @form
227  bare!: Form;
228
229  @form("named")
230  named!: Form;
231
232  @form()
233  @state
234  conflicting!: Form;
235
236  @form()
237  get current(): Form { return this.profile; }
238
239  @form()
240  set current(value: Form) {}
241
242  parameter(@form() value: Form) {}
243}
244"#;
245        let parsed = parse_file("src/Profile.tsx", source);
246
247        assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics);
248        assert!(parsed.classes[0].decorators[0].is_invoked);
249        let profile = &parsed.classes[1];
250        assert!(profile.properties[0].is_identifier_name);
251        assert!(profile.properties[0].is_definite_assignment);
252        assert!(!profile.properties[0].is_declare);
253        assert!(profile.properties[1].is_declare);
254        assert!(!profile.properties[2].decorators[0].is_invoked);
255        assert_eq!(profile.properties[2].decorators[0].argument_count, 0);
256        assert!(profile.properties[3].decorators[0].is_invoked);
257        assert_eq!(profile.properties[3].decorators[0].argument_count, 1);
258        assert_eq!(profile.properties[3].decorators[0].argument_spans.len(), 1);
259        let named_argument = profile.properties[3].decorators[0].argument_spans[0];
260        assert_eq!(
261            &source[named_argument.start..named_argument.end],
262            "\"named\""
263        );
264        assert_eq!(profile.properties[4].decorators.len(), 2);
265        assert_eq!(profile.properties[4].decorators[1].name, "state");
266        assert!(!profile.properties[4].decorators[1].is_invoked);
267        assert!(profile.methods[0].is_getter);
268        assert!(!profile.methods[0].is_setter);
269        assert!(profile.methods[1].is_setter);
270        assert_eq!(profile.methods[2].parameters[0].decorators[0].name, "form");
271        assert_eq!(
272            profile.properties[0].span.start,
273            source.find("@form()\n  profile").unwrap()
274        );
275        assert!(parsed.local_type_bindings.contains(&"Profile".to_string()));
276    }
277
278    #[test]
279    fn retains_module_local_type_namespace_bindings() {
280        let parsed = parse_file(
281            "src/types.ts",
282            r#"
283class ClassType {}
284type AliasType = string;
285interface InterfaceType {}
286enum EnumType { Value }
287import ImportEqualsType = require("./type");
288"#,
289        );
290
291        assert_eq!(
292            parsed.local_type_bindings,
293            [
294                "AliasType",
295                "ClassType",
296                "EnumType",
297                "ImportEqualsType",
298                "InterfaceType",
299            ]
300        );
301    }
302
303    #[test]
304    fn retains_normalized_form_field_designators_targets_values_and_provenance() {
305        let source = r#"
306@field(this.profileForm)
307class InvalidTarget {}
308
309@component("profile-editor")
310class ProfileEditor {
311  @form() profileForm!: Form;
312  @field(this.profileForm) displayName = "Austin";
313  @field(this.profileForm) address = { city: "", postalCode: "" };
314  @field
315  bare = "";
316  @field("profileForm") stringDesignator = "";
317  @field(this.forms.profile) chained = "";
318  @field(this.profileForm) ["computed"] = "";
319  @field(this.profileForm) #privateName = "";
320  @field(this.profileForm) method() {}
321  parameter(@field(this.profileForm) value: string) {}
322}
323"#;
324        let parsed = parse_file("src/ProfileEditor.tsx", source);
325        let editor = &parsed.classes[1];
326        let display = &editor.properties[1];
327
328        assert_eq!(display.decorators[0].argument_count, 1);
329        assert_eq!(
330            display.decorators[0]
331                .this_member_argument
332                .as_ref()
333                .map(|designator| designator.member.as_str()),
334            Some("profileForm")
335        );
336        assert_eq!(
337            display.initializer_literal,
338            Some(ParsedSerializableValue::String("Austin".to_string()))
339        );
340        assert!(matches!(
341            editor.properties[2].initializer_literal,
342            Some(ParsedSerializableValue::Object(_))
343        ));
344        assert!(!editor.properties[3].decorators[0].is_invoked);
345        assert!(editor.properties[4].decorators[0]
346            .this_member_argument
347            .is_none());
348        assert_eq!(
349            editor.properties[4].decorators[0].argument.as_deref(),
350            Some("profileForm")
351        );
352        assert!(editor.properties[5].decorators[0]
353            .this_member_argument
354            .is_none());
355        assert!(!editor.properties[6].is_identifier_name);
356        assert!(!editor.properties[7].is_identifier_name);
357        assert_eq!(editor.methods[0].decorators[0].name, "field");
358        assert_eq!(editor.methods[1].parameters[0].decorators[0].name, "field");
359        assert_eq!(
360            display.span.start,
361            source.find("@field(this.profileForm) displayName").unwrap()
362        );
363    }
364
365    #[test]
366    fn retains_every_static_decorator_argument_without_admitting_its_semantics() {
367        let parsed = parse_file(
368            "src/NestedField.tsx",
369            r#"@component("x-nested") class NestedField {
370  @field("profile", "address.street") street = "";
371  render() { return <div />; }
372}"#,
373        );
374        let decorator = &parsed.classes[0].properties[0].decorators[0];
375        assert_eq!(decorator.name, "field");
376        assert_eq!(decorator.argument_count, 2);
377        assert_eq!(
378            decorator.arguments,
379            vec![
380                Some("profile".to_string()),
381                Some("address.street".to_string())
382            ]
383        );
384        assert_eq!(decorator.argument, Some("profile".to_string()));
385    }
386
387    #[test]
388    fn retains_normalized_form_control_attribute_facts() {
389        let source = r#"
390@component("profile-editor")
391class ProfileEditor {
392  render() {
393    return <main>
394      <input type="radio" value="email" field={this.contact} />
395      <input type={this.kind} field={this.contact} {...props} />
396      <input field={this["contact"]} />
397      <select multiple={this.multiple} field={this.tags} />
398    </main>;
399  }
400}
401"#;
402        let parsed = parse_file("src/ProfileEditor.tsx", source);
403        let super::ParsedJsxNode::Element(root) = &parsed.classes[0].methods[0].jsx_roots[0] else {
404            panic!("element root");
405        };
406        let elements = root
407            .children
408            .iter()
409            .filter_map(|child| match child {
410                super::ParsedJsxChild::Element(element) => Some(element),
411                _ => None,
412            })
413            .collect::<Vec<_>>();
414
415        let radio = &elements[0];
416        let value = radio
417            .attributes
418            .iter()
419            .find(|attribute| attribute.name == "value")
420            .expect("radio value");
421        let field = radio
422            .attributes
423            .iter()
424            .find(|attribute| attribute.name == "field")
425            .expect("field binding");
426        assert_eq!(
427            value.constant_value,
428            Some(ParsedSerializableValue::String("email".to_string()))
429        );
430        assert_eq!(
431            field
432                .this_member
433                .as_ref()
434                .map(|member| member.member.as_str()),
435            Some("contact")
436        );
437        assert_eq!(
438            &source[field.expression_span.unwrap().start..field.expression_span.unwrap().end],
439            "this.contact"
440        );
441        assert!(elements[1]
442            .attributes
443            .iter()
444            .any(|attribute| matches!(attribute.value, ParsedJsxAttributeValue::Spread(_))));
445        assert!(elements[2].attributes[0].this_member.is_none());
446        assert!(elements[3]
447            .attributes
448            .iter()
449            .find(|attribute| attribute.name == "multiple")
450            .expect("multiple")
451            .this_member
452            .is_some());
453    }
454
455    #[test]
456    fn retains_canonical_component_tag_names_and_exact_name_spans() {
457        let source = r#"
458@component("x-page")
459class Page extends Component {
460  render() { return <main><Card /><Registry.Card /></main>; }
461}
462"#;
463        let parsed = parse_file("src/Page.tsx", source);
464        let super::ParsedJsxNode::Element(root) = &parsed.classes[0].methods[0].jsx_roots[0] else {
465            panic!("element root");
466        };
467        let elements = root
468            .children
469            .iter()
470            .filter_map(|child| match child {
471                super::ParsedJsxChild::Element(element) => Some(element),
472                _ => None,
473            })
474            .collect::<Vec<_>>();
475
476        assert_eq!(elements[0].name, "Card");
477        assert_eq!(elements[1].name, "Registry.Card");
478        assert_eq!(
479            &source[elements[0].name_span.start..elements[0].name_span.end],
480            "Card"
481        );
482        assert_eq!(
483            &source[elements[1].name_span.start..elements[1].name_span.end],
484            "Registry.Card"
485        );
486    }
487
488    #[test]
489    fn retains_static_member_provider_designators_and_value_expressions() {
490        let parsed = parse_file(
491            "src/AppShell.tsx",
492            r#"
493@component("x-app-shell")
494class AppShell extends Component {
495  @provide(AppShell.theme)
496  providedTheme: string = this.theme ?? "light";
497}
498"#,
499        );
500        let property = &parsed.classes[0].properties[0];
501        let decorator = &property.decorators[0];
502        let designator = decorator.static_member_argument.as_ref().unwrap();
503
504        assert_eq!(decorator.name, "provide");
505        assert_eq!(decorator.argument_count, 1);
506        assert_eq!(designator.object, "AppShell");
507        assert_eq!(designator.member, "theme");
508        assert!(property.initializer_expression.is_some());
509    }
510
511    #[test]
512    fn retains_ordered_effect_statement_syntax_and_unsupported_forms() {
513        let parsed = parse_file(
514            "src/Effects.tsx",
515            r#"
516@component("x-effects")
517class Effects extends Component {
518  @effect()
519  sync() {
520    document.title = this.title;
521    analytics.track("view", this.total + this.tax);
522    return;
523  }
524
525  @effect()
526  invalid() {
527    const title = this.title;
528    if (this.enabled) { analytics.track("enabled"); }
529  }
530}
531"#,
532        );
533        let methods = &parsed.classes[0].methods;
534        let body = methods[0].effect_body.as_ref().expect("effect body");
535        assert_eq!(body.statements.len(), 3);
536        assert!(matches!(
537            body.statements[0].kind,
538            ParsedEffectStatementKind::StaticMemberAssignment { .. }
539        ));
540        assert!(matches!(
541            body.statements[1].kind,
542            ParsedEffectStatementKind::CapabilityCall { .. }
543        ));
544        assert!(matches!(
545            body.statements[2].kind,
546            ParsedEffectStatementKind::EffectReturn { value: None }
547        ));
548
549        let invalid = methods[1].effect_body.as_ref().expect("effect body");
550        assert!(matches!(
551            invalid.statements[0].kind,
552            ParsedEffectStatementKind::Unsupported(
553                ParsedUnsupportedEffectStatementKind::LocalDeclaration
554            )
555        ));
556        assert!(matches!(
557            invalid.statements[1].kind,
558            ParsedEffectStatementKind::Unsupported(ParsedUnsupportedEffectStatementKind::Branch)
559        ));
560    }
561
562    #[test]
563    fn retains_submit_decorator_designator_and_method_signature_facts() {
564        let parsed = parse_file(
565            "src/Profile.tsx",
566            r#"
567@component("profile")
568class Profile {
569  @action() @submit(this.profileForm) save(): void {}
570  @submit invalid(value: string): string { return ""; }
571  @action() @submit(this.profileForm) static saveStatic(): void {}
572}
573"#,
574        );
575        let methods = &parsed.classes[0].methods;
576        let submit = methods[0]
577            .decorators
578            .iter()
579            .find(|decorator| decorator.name == "submit")
580            .expect("submit decorator");
581        assert!(submit.is_invoked);
582        assert_eq!(submit.argument_count, 1);
583        assert_eq!(
584            submit
585                .this_member_argument
586                .as_ref()
587                .map(|value| value.member.as_str()),
588            Some("profileForm")
589        );
590        assert_eq!(
591            methods[0]
592                .return_type_annotation
593                .as_ref()
594                .map(|annotation| annotation.text.as_str()),
595            Some("void")
596        );
597        assert_eq!(methods[1].parameters.len(), 1);
598        assert!(
599            !methods[1]
600                .decorators
601                .iter()
602                .find(|decorator| decorator.name == "submit")
603                .expect("bare submit")
604                .is_invoked
605        );
606        assert!(methods[2].is_static);
607    }
608}