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#[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}