1use std::path::Path;
2
3use oxc_allocator::Allocator;
4use oxc_ast::ast::{Comment, Program};
5use oxc_ast_visit::Visit;
6use oxc_parser::Parser;
7use oxc_span::SourceType;
8
9use crate::ExportInfo;
10use crate::ModuleInfo;
11use crate::astro::{is_astro_file, parse_astro_to_module};
12use crate::css::{is_css_file, parse_css_to_module};
13use crate::glimmer::{is_glimmer_file, strip_glimmer_templates};
14use crate::graphql::{is_graphql_file, parse_graphql_to_module};
15use crate::html::{is_html_file, parse_html_to_module_with_complexity};
16use crate::mdx::{is_mdx_file, parse_mdx_to_module};
17use crate::sfc::{is_sfc_file, parse_sfc_to_module};
18use crate::visitor::ModuleInfoExtractor;
19use fallow_types::discover::FileId;
20use fallow_types::extract::{ImportInfo, VisibilityTag};
21
22fn source_type_for_path(path: &Path) -> SourceType {
23 match path.extension().and_then(|ext| ext.to_str()) {
24 Some("gts") => SourceType::ts(),
25 Some("gjs") => SourceType::mjs(),
26 _ => SourceType::from_path(path).unwrap_or_default(),
27 }
28}
29
30pub fn parse_source_to_module(
37 file_id: FileId,
38 path: &Path,
39 source: &str,
40 content_hash: u64,
41 need_complexity: bool,
42) -> ModuleInfo {
43 let mut module =
44 parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
45 module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
46 module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
47 module
48}
49
50fn parse_source_to_module_inner(
51 file_id: FileId,
52 path: &Path,
53 source: &str,
54 content_hash: u64,
55 need_complexity: bool,
56) -> ModuleInfo {
57 let source = crate::strip_bom(source);
58 if is_sfc_file(path) {
59 return parse_sfc_to_module(file_id, path, source, content_hash, need_complexity);
60 }
61 if is_astro_file(path) {
62 return parse_astro_to_module(file_id, source, content_hash);
63 }
64 if is_mdx_file(path) {
65 return parse_mdx_to_module(file_id, source, content_hash);
66 }
67 if is_css_file(path) {
68 return parse_css_to_module(file_id, path, source, content_hash);
69 }
70 if is_graphql_file(path) {
71 return parse_graphql_to_module(file_id, source, content_hash);
72 }
73 if is_html_file(path) {
74 return parse_html_to_module_with_complexity(
75 file_id,
76 source,
77 content_hash,
78 need_complexity,
79 );
80 }
81
82 let stripped_glimmer_source = is_glimmer_file(path)
83 .then(|| strip_glimmer_templates(source))
84 .flatten();
85 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
86 let source_type = source_type_for_path(path);
87 let allocator = Allocator::default();
88 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
89
90 let mut parsed_suppressions =
91 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
92
93 let mut extractor = ModuleInfoExtractor::new();
94 extractor.visit_program(&parser_return.program);
95 extractor.resolve_pending_local_export_specifiers();
96
97 let mut template_used_imports =
98 collect_glimmer_template_into_extractor(&mut extractor, path, source);
99
100 let mut semantic_usage = compute_semantic_usage(
101 &parser_return.program,
102 &extractor.imports,
103 &template_used_imports,
104 );
105
106 let line_offsets = fallow_types::extract::compute_line_offsets(source);
107
108 let mut complexity = if need_complexity {
109 crate::complexity::compute_complexity(&parser_return.program, parser_source, &line_offsets)
110 } else {
111 Vec::new()
112 };
113 if need_complexity {
114 append_inline_template_complexity(
115 &mut complexity,
116 &extractor.inline_template_findings,
117 &line_offsets,
118 );
119 }
120
121 let mut flag_uses = crate::flags::extract_flags(
122 &parser_return.program,
123 &line_offsets,
124 &[], &[], false, );
128
129 let total_extracted =
130 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
131 let mut used_retry = false;
132 if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
133 let jsx_type = if source_type.is_typescript() {
134 SourceType::tsx()
135 } else {
136 SourceType::jsx()
137 };
138 let allocator2 = Allocator::default();
139 let retry_return = Parser::new(&allocator2, parser_source, jsx_type).parse();
140 let mut retry_extractor = ModuleInfoExtractor::new();
141 retry_extractor.visit_program(&retry_return.program);
142 retry_extractor.resolve_pending_local_export_specifiers();
143 let retry_total = retry_extractor.exports.len()
144 + retry_extractor.imports.len()
145 + retry_extractor.re_exports.len();
146 if retry_total > total_extracted {
147 template_used_imports =
148 collect_glimmer_template_into_extractor(&mut retry_extractor, path, source);
149 semantic_usage = compute_semantic_usage(
150 &retry_return.program,
151 &retry_extractor.imports,
152 &template_used_imports,
153 );
154 if need_complexity {
155 complexity = crate::complexity::compute_complexity(
156 &retry_return.program,
157 parser_source,
158 &line_offsets,
159 );
160 append_inline_template_complexity(
161 &mut complexity,
162 &retry_extractor.inline_template_findings,
163 &line_offsets,
164 );
165 }
166 flag_uses =
167 crate::flags::extract_flags(&retry_return.program, &line_offsets, &[], &[], false);
168 parsed_suppressions =
169 crate::suppress::parse_suppressions(&retry_return.program.comments, source);
170 apply_jsdoc_visibility_tags(
171 &mut retry_extractor.exports,
172 &retry_return.program.comments,
173 source,
174 );
175 extract_jsdoc_import_types(
176 &mut retry_extractor.imports,
177 &retry_return.program.comments,
178 source,
179 );
180 extractor = retry_extractor;
181 used_retry = true;
182 }
183 }
184
185 if !used_retry {
186 apply_jsdoc_visibility_tags(
187 &mut extractor.exports,
188 &parser_return.program.comments,
189 source,
190 );
191 extract_jsdoc_import_types(
192 &mut extractor.imports,
193 &parser_return.program.comments,
194 source,
195 );
196 }
197
198 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
199 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
200 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
201 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
202 info.auto_import_candidates = semantic_usage.auto_import_candidates;
203 info.line_offsets = line_offsets;
204 info.complexity = complexity;
205 info.flag_uses = flag_uses;
206
207 info
208}
209
210fn collect_glimmer_template_into_extractor(
232 extractor: &mut ModuleInfoExtractor,
233 path: &Path,
234 source: &str,
235) -> rustc_hash::FxHashSet<String> {
236 use rustc_hash::FxHashSet;
237
238 if !is_glimmer_file(path) {
239 return FxHashSet::default();
240 }
241 let template_ranges = crate::glimmer::find_template_ranges(source);
242 if template_ranges.is_empty() {
243 return FxHashSet::default();
244 }
245
246 let imported_bindings: FxHashSet<String> = extractor
247 .imports
248 .iter()
249 .filter(|import| !import.local_name.is_empty())
250 .map(|import| import.local_name.clone())
251 .collect();
252
253 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
254 source,
255 &template_ranges,
256 &imported_bindings,
257 );
258 extractor.member_accesses.extend(usage.member_accesses);
259 usage.used_bindings
260}
261
262fn append_inline_template_complexity(
273 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
274 findings: &[crate::visitor::InlineTemplateFinding],
275 line_offsets: &[u32],
276) {
277 for finding in findings {
278 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
279 &finding.template_source,
280 ) else {
281 continue;
282 };
283 let (line, col) =
284 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
285 fc.line = line;
286 fc.col = col;
287 complexity.push(fc);
288 }
289}
290
291fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
301 if exports.is_empty() || comments.is_empty() {
302 return;
303 }
304
305 let mut tag_offsets: Vec<(u32, VisibilityTag)> = Vec::new();
306 for comment in comments {
307 if comment.is_jsdoc() {
308 let content_span = comment.content_span();
309 let start = content_span.start as usize;
310 let end = (content_span.end as usize).min(source.len());
311 if start < end {
312 let text = &source[start..end];
313 let tag = if has_public_tag(text) {
314 VisibilityTag::Public
315 } else if has_internal_tag(text) {
316 VisibilityTag::Internal
317 } else if has_alpha_tag(text) {
318 VisibilityTag::Alpha
319 } else if has_beta_tag(text) {
320 VisibilityTag::Beta
321 } else if has_expected_unused_tag(text) {
322 VisibilityTag::ExpectedUnused
323 } else {
324 continue;
325 };
326 tag_offsets.push((comment.attached_to, tag));
327 }
328 }
329 }
330
331 if tag_offsets.is_empty() {
332 return;
333 }
334
335 tag_offsets.sort_unstable_by_key(|&(offset, _)| offset);
336
337 for export in exports.iter_mut() {
338 if export.span.start == 0 && export.span.end == 0 {
339 continue;
340 }
341
342 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _)| o) {
343 export.visibility = tag_offsets[idx].1;
344 continue;
345 }
346
347 let idx = tag_offsets.partition_point(|&(o, _)| o <= export.span.start);
348 if idx > 0 {
349 let (offset, tag) = tag_offsets[idx - 1];
350 let offset = offset as usize;
351 let export_start = export.span.start as usize;
352 if offset < export_start && export_start <= source.len() {
353 let between = &source[offset..export_start];
354 if between.starts_with("export") && !between.contains(';') && !between.contains('}')
355 {
356 export.visibility = tag;
357 }
358 }
359 }
360 }
361}
362
363fn has_internal_tag(comment_text: &str) -> bool {
365 for (i, _) in comment_text.match_indices("@internal") {
366 let after = i + "@internal".len();
367 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
368 return true;
369 }
370 }
371 false
372}
373
374fn has_beta_tag(comment_text: &str) -> bool {
376 for (i, _) in comment_text.match_indices("@beta") {
377 let after = i + "@beta".len();
378 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
379 return true;
380 }
381 }
382 false
383}
384
385fn has_alpha_tag(comment_text: &str) -> bool {
387 for (i, _) in comment_text.match_indices("@alpha") {
388 let after = i + "@alpha".len();
389 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
390 return true;
391 }
392 }
393 false
394}
395
396fn has_expected_unused_tag(comment_text: &str) -> bool {
398 for (i, _) in comment_text.match_indices("@expected-unused") {
399 let after = i + "@expected-unused".len();
400 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
401 return true;
402 }
403 }
404 false
405}
406
407const fn is_ident_char(b: u8) -> bool {
409 b.is_ascii_alphanumeric() || b == b'_'
410}
411
412fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
436 if comments.is_empty() {
437 return;
438 }
439
440 for comment in comments {
441 if !comment.is_jsdoc() {
442 continue;
443 }
444 let content_span = comment.content_span();
445 let start = content_span.start as usize;
446 let end = (content_span.end as usize).min(source.len());
447 if start >= end {
448 continue;
449 }
450 scan_jsdoc_imports_in(&source[start..end], imports);
451 }
452}
453
454fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
462 let bytes = body.as_bytes();
463 let mut cursor = 0;
464 while let Some(rel) = body[cursor..].find("import(") {
465 let import_pos = cursor + rel;
466 if !is_inside_jsdoc_type_brace_group(bytes, import_pos) {
467 cursor = import_pos + "import(".len();
468 continue;
469 }
470 let open = import_pos + "import(".len();
471 cursor = open;
472 if open >= bytes.len() {
473 break;
474 }
475 let mut i = open;
476 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
477 i += 1;
478 }
479 if i >= bytes.len() {
480 break;
481 }
482 let quote = bytes[i];
483 if quote != b'\'' && quote != b'"' {
484 continue;
485 }
486 let path_start = i + 1;
487 let Some(rel_close) = body[path_start..].find(quote as char) else {
488 break;
489 };
490 let path_end = path_start + rel_close;
491 let path = &body[path_start..path_end];
492 if path.is_empty() {
493 cursor = path_end + 1;
494 continue;
495 }
496 let mut j = path_end + 1;
497 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
498 j += 1;
499 }
500 if j >= bytes.len() || bytes[j] != b')' {
501 cursor = path_end + 1;
502 continue;
503 }
504 j += 1;
505 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
506 j += 1;
507 }
508 cursor = j;
509 if j >= bytes.len() || bytes[j] != b'.' {
510 imports.push(ImportInfo {
511 source: path.to_string(),
512 imported_name: fallow_types::extract::ImportedName::SideEffect,
513 local_name: String::new(),
514 is_type_only: true,
515 from_style: false,
516 span: oxc_span::Span::default(),
517 source_span: oxc_span::Span::default(),
518 });
519 continue;
520 }
521 j += 1;
522 let name_start = j;
523 while j < bytes.len() && is_ident_char(bytes[j]) {
524 j += 1;
525 }
526 if name_start == j {
527 continue;
528 }
529 let member = &body[name_start..j];
530 cursor = j;
531 imports.push(ImportInfo {
532 source: path.to_string(),
533 imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
534 local_name: String::new(),
535 is_type_only: true,
536 from_style: false,
537 span: oxc_span::Span::default(),
538 source_span: oxc_span::Span::default(),
539 });
540 }
541}
542
543fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize) -> bool {
547 let Some(open_brace) = enclosing_jsdoc_brace_start(body, pos) else {
548 return false;
549 };
550
551 let prefix = line_prefix_before(body, open_brace);
552 if jsdoc_line_prefix_has_type_tag(prefix) {
553 return true;
554 }
555
556 strip_jsdoc_line_prefix(prefix).is_empty()
557 && preceding_jsdoc_line_has_type_tag(body, open_brace)
558 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
559}
560
561fn enclosing_jsdoc_brace_start(body: &[u8], pos: usize) -> Option<usize> {
562 let mut stack = Vec::new();
563 let limit = pos.min(body.len());
564 for (idx, &b) in body[..limit].iter().enumerate() {
565 match b {
566 b'{' => stack.push(idx),
567 b'}' => {
568 stack.pop();
569 }
570 _ => {}
571 }
572 }
573 stack.pop()
574}
575
576fn line_prefix_before(body: &[u8], pos: usize) -> &str {
577 let start = body[..pos]
578 .iter()
579 .rposition(|&b| b == b'\n')
580 .map_or(0, |idx| idx + 1);
581 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
582}
583
584fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
585 let trimmed = prefix.trim_start();
586 trimmed
587 .strip_prefix('*')
588 .map_or(trimmed, |rest| rest.trim_start())
589}
590
591fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
592 const TYPE_TAGS: [&str; 17] = [
593 "@arg",
594 "@argument",
595 "@augments",
596 "@callback",
597 "@enum",
598 "@extends",
599 "@implements",
600 "@param",
601 "@property",
602 "@prop",
603 "@return",
604 "@returns",
605 "@satisfies",
606 "@template",
607 "@this",
608 "@type",
609 "@typedef",
610 ];
611
612 let prefix = strip_jsdoc_line_prefix(prefix);
613 TYPE_TAGS
614 .iter()
615 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
616}
617
618fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
619 for (idx, _) in text.match_indices(tag) {
620 let after = idx + tag.len();
621 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
622 return true;
623 }
624 }
625 false
626}
627
628fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
629 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
630 return false;
631 };
632
633 let line_start = body[..line_end]
634 .iter()
635 .rposition(|&b| b == b'\n')
636 .map_or(0, |idx| idx + 1);
637
638 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
639}
640
641fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
642 let mut at_line_start = true;
643 let mut i = start.min(body.len());
644 let end = end.min(body.len());
645 while i < end {
646 match body[i] {
647 b'\n' => {
648 at_line_start = true;
649 i += 1;
650 }
651 b'\r' | b'\t' | b' ' => {
652 i += 1;
653 }
654 b'*' if at_line_start => {
655 at_line_start = false;
656 i += 1;
657 }
658 _ => return false,
659 }
660 }
661 true
662}
663
664fn has_public_tag(comment_text: &str) -> bool {
666 for (i, _) in comment_text.match_indices("@public") {
667 let after = i + "@public".len();
668 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
669 return true;
670 }
671 }
672 for (i, _) in comment_text.match_indices("@api") {
673 let after = i + "@api".len();
674 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
675 let rest = comment_text[after..].trim_start();
676 if rest.starts_with("public") {
677 let after_public = "public".len();
678 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
679 return true;
680 }
681 }
682 }
683 }
684 false
685}
686
687#[derive(Debug, Default, PartialEq, Eq)]
688pub struct ImportBindingUsage {
689 pub unused: Vec<String>,
690 pub type_referenced: Vec<String>,
691 pub value_referenced: Vec<String>,
692}
693
694#[derive(Debug, Default, PartialEq, Eq)]
695pub struct SemanticUsage {
696 pub import_binding_usage: ImportBindingUsage,
697 pub auto_import_candidates: Vec<String>,
698}
699
700pub fn compute_semantic_usage(
701 program: &Program<'_>,
702 imports: &[ImportInfo],
703 template_used: &rustc_hash::FxHashSet<String>,
704) -> SemanticUsage {
705 use oxc_semantic::SemanticBuilder;
706 use rustc_hash::FxHashSet;
707
708 let semantic_ret = SemanticBuilder::new().build(program);
709 let semantic = semantic_ret.semantic;
710 let scoping = semantic.scoping();
711 let root_scope = scoping.root_scope_id();
712
713 let mut unused = Vec::new();
714 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
715 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
716 for import in imports {
717 if import.local_name.is_empty() {
718 continue;
719 }
720 let name = oxc_str::Ident::from(import.local_name.as_str());
721 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
722 let mut has_references = false;
723 let mut has_type_references = false;
724 let mut has_value_references = false;
725
726 for reference in scoping.get_resolved_references(symbol_id) {
727 has_references = true;
728 has_type_references |= reference.is_type();
729 has_value_references |= reference.is_value();
730 }
731
732 if !has_references {
733 if !template_used.contains(&import.local_name) {
734 unused.push(import.local_name.clone());
735 }
736 continue;
737 }
738
739 if has_type_references {
740 type_referenced_bindings.insert(import.local_name.clone());
741 }
742 if has_value_references {
743 value_referenced_bindings.insert(import.local_name.clone());
744 }
745 }
746 }
747
748 unused.sort_unstable();
749
750 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
751 type_referenced_bindings.sort_unstable();
752
753 let mut value_referenced_bindings: Vec<String> =
754 value_referenced_bindings.into_iter().collect();
755 value_referenced_bindings.sort_unstable();
756
757 SemanticUsage {
758 import_binding_usage: ImportBindingUsage {
759 unused,
760 type_referenced: type_referenced_bindings,
761 value_referenced: value_referenced_bindings,
762 },
763 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
764 }
765}
766
767pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
768 use oxc_semantic::SemanticBuilder;
769
770 let semantic_ret = SemanticBuilder::new().build(program);
771 let semantic = semantic_ret.semantic;
772 compute_auto_import_candidates_from_semantic(semantic.scoping())
773}
774
775fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
776 use rustc_hash::FxHashSet;
777
778 let mut candidates: FxHashSet<String> = FxHashSet::default();
779 for (name, reference_ids) in scoping.root_unresolved_references() {
780 if reference_ids
781 .iter()
782 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
783 {
784 candidates.insert(name.as_str().to_string());
785 }
786 }
787
788 let mut candidates: Vec<String> = candidates.into_iter().collect();
789 candidates.sort_unstable();
790 candidates
791}
792
793pub fn compute_import_binding_usage(
810 program: &Program<'_>,
811 imports: &[ImportInfo],
812 template_used: &rustc_hash::FxHashSet<String>,
813) -> ImportBindingUsage {
814 compute_semantic_usage(program, imports, template_used).import_binding_usage
815}
816
817#[cfg(test)]
818mod tests {
819 use super::{
820 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
821 scan_jsdoc_imports_in,
822 };
823 use fallow_types::discover::FileId;
824 use fallow_types::extract::{ImportInfo, ImportedName};
825 use std::path::Path;
826
827 #[test]
828 fn has_public_tag_matches_bare_tag() {
829 assert!(has_public_tag(" * @public"));
830 }
831
832 #[test]
833 fn has_public_tag_matches_api_public_variant() {
834 assert!(has_public_tag(" * @api public"));
835 }
836
837 #[test]
838 fn has_public_tag_rejects_partial_word() {
839 assert!(!has_public_tag(" * @publicly"));
840 }
841
842 #[test]
843 fn has_public_tag_rejects_at_apipublic() {
844 assert!(!has_public_tag(" * @apipublic"));
845 }
846
847 #[test]
848 fn has_public_tag_rejects_missing_at() {
849 assert!(!has_public_tag(" * public"));
850 }
851
852 #[test]
853 fn has_internal_tag_matches_bare_tag() {
854 assert!(has_internal_tag(" * @internal"));
855 }
856
857 #[test]
858 fn has_internal_tag_rejects_partial_word() {
859 assert!(!has_internal_tag(" * @internalizer"));
860 }
861
862 #[test]
863 fn has_internal_tag_rejects_missing_at() {
864 assert!(!has_internal_tag(" * internal"));
865 }
866
867 #[test]
868 fn has_beta_tag_matches_bare_tag() {
869 assert!(has_beta_tag(" * @beta"));
870 }
871
872 #[test]
873 fn has_beta_tag_rejects_partial_word() {
874 assert!(!has_beta_tag(" * @betaware"));
875 }
876
877 #[test]
878 fn has_beta_tag_rejects_missing_at() {
879 assert!(!has_beta_tag(" * beta"));
880 }
881
882 #[test]
883 fn alpha_tag_standalone() {
884 assert!(has_alpha_tag("@alpha"));
885 }
886
887 #[test]
888 fn alpha_tag_with_text() {
889 assert!(has_alpha_tag("@alpha Some description"));
890 }
891
892 #[test]
893 fn alpha_tag_not_prefix() {
894 assert!(!has_alpha_tag("@alphabet"));
895 }
896
897 #[test]
898 fn has_alpha_tag_rejects_missing_at() {
899 assert!(!has_alpha_tag(" * alpha"));
900 }
901
902 fn scan(body: &str) -> Vec<ImportInfo> {
903 let mut imports = Vec::new();
904 scan_jsdoc_imports_in(body, &mut imports);
905 imports
906 }
907
908 #[test]
909 fn scan_jsdoc_single_import_with_member() {
910 let imports = scan(" * @param foo {import('./types').Foo}");
911 assert_eq!(imports.len(), 1);
912 assert_eq!(imports[0].source, "./types");
913 assert_eq!(
914 imports[0].imported_name,
915 ImportedName::Named("Foo".to_string())
916 );
917 assert!(imports[0].is_type_only);
918 assert!(imports[0].local_name.is_empty());
919 }
920
921 #[test]
922 fn script_auto_import_candidates_capture_zero_import_value_refs() {
923 let info = parse_source_to_module(
924 FileId(0),
925 Path::new("pages/index.ts"),
926 r"
927 useCounter();
928 const price = formatPrice(10);
929 const localOnly = () => null;
930 localOnly();
931 type Local = UseTypeOnly;
932 ",
933 0,
934 false,
935 );
936
937 assert!(
938 info.auto_import_candidates
939 .contains(&"formatPrice".to_string())
940 );
941 assert!(
942 info.auto_import_candidates
943 .contains(&"useCounter".to_string())
944 );
945 assert!(
946 !info
947 .auto_import_candidates
948 .contains(&"UseTypeOnly".to_string())
949 );
950 assert!(
951 !info
952 .auto_import_candidates
953 .contains(&"localOnly".to_string())
954 );
955 }
956
957 #[test]
958 fn script_auto_import_candidates_skip_explicit_imports() {
959 let info = parse_source_to_module(
960 FileId(0),
961 Path::new("pages/index.ts"),
962 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
963 0,
964 false,
965 );
966
967 assert!(
968 !info
969 .auto_import_candidates
970 .contains(&"useCounter".to_string())
971 );
972 assert!(
973 info.auto_import_candidates
974 .contains(&"useOther".to_string())
975 );
976 }
977
978 #[test]
979 fn scan_jsdoc_double_quoted_path() {
980 let imports = scan(r#" * @type {import("./types").Foo}"#);
981 assert_eq!(imports.len(), 1);
982 assert_eq!(imports[0].source, "./types");
983 }
984
985 #[test]
986 fn scan_jsdoc_multiple_imports_in_same_body() {
987 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
988 assert_eq!(imports.len(), 2);
989 assert_eq!(imports[0].source, "./a");
990 assert_eq!(imports[1].source, "./b");
991 }
992
993 #[test]
994 fn scan_jsdoc_union_annotation_captures_both_members() {
995 let imports = scan(" * @type {import('./a').A | import('./b').B}");
996 assert_eq!(imports.len(), 2);
997 assert_eq!(
998 imports[0].imported_name,
999 ImportedName::Named("A".to_string())
1000 );
1001 assert_eq!(
1002 imports[1].imported_name,
1003 ImportedName::Named("B".to_string())
1004 );
1005 }
1006
1007 #[test]
1008 fn scan_jsdoc_nested_member_uses_first_segment() {
1009 let imports = scan(" * @type {import('./types').ns.Foo}");
1010 assert_eq!(imports.len(), 1);
1011 assert_eq!(
1012 imports[0].imported_name,
1013 ImportedName::Named("ns".to_string())
1014 );
1015 }
1016
1017 #[test]
1018 fn scan_jsdoc_parent_relative_path() {
1019 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1020 assert_eq!(imports.len(), 1);
1021 assert_eq!(imports[0].source, "../lib/types.js");
1022 }
1023
1024 #[test]
1025 fn scan_jsdoc_bare_package_specifier() {
1026 let imports = scan(" * @type {import('@scope/pkg').Client}");
1027 assert_eq!(imports.len(), 1);
1028 assert_eq!(imports[0].source, "@scope/pkg");
1029 assert_eq!(
1030 imports[0].imported_name,
1031 ImportedName::Named("Client".to_string())
1032 );
1033 }
1034
1035 #[test]
1036 fn scan_jsdoc_without_member_is_side_effect() {
1037 let imports = scan(" * @type {import('./types')}");
1038 assert_eq!(imports.len(), 1);
1039 assert_eq!(imports[0].source, "./types");
1040 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1041 assert!(imports[0].is_type_only);
1042 }
1043
1044 #[test]
1045 fn scan_jsdoc_empty_path_is_skipped() {
1046 let imports = scan(" * @type {import('').Foo}");
1047 assert!(imports.is_empty());
1048 }
1049
1050 #[test]
1051 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1052 let imports = scan(" * @type {import('./truncated");
1053 assert!(imports.is_empty());
1054 }
1055
1056 #[test]
1057 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1058 let imports = scan(" * @type {import('./types'.Foo}");
1059 assert!(imports.is_empty());
1060 }
1061
1062 #[test]
1063 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1064 let imports = scan(" * @type {import('./types') .Foo}");
1065 assert_eq!(imports.len(), 1);
1066 assert_eq!(imports[0].source, "./types");
1067 assert_eq!(
1068 imports[0].imported_name,
1069 ImportedName::Named("Foo".to_string())
1070 );
1071 }
1072
1073 #[test]
1074 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1075 let imports = scan(" * @type {import( './types').Foo}");
1076 assert_eq!(imports.len(), 1);
1077 assert_eq!(imports[0].source, "./types");
1078 }
1079
1080 #[test]
1081 fn scan_jsdoc_non_quote_after_paren_skipped() {
1082 let imports = scan(" * @type {import(foo).Bar}");
1083 assert!(imports.is_empty());
1084 }
1085
1086 #[test]
1087 fn scan_jsdoc_ignores_prose_with_import_word() {
1088 let imports = scan(" * This is an important note about imports.");
1089 assert!(imports.is_empty());
1090 }
1091
1092 #[test]
1093 fn scan_jsdoc_utf8_path_works() {
1094 let imports = scan(" * @type {import('./héllo').Foo}");
1095 assert_eq!(imports.len(), 1);
1096 assert_eq!(imports[0].source, "./héllo");
1097 }
1098
1099 #[test]
1100 fn scan_jsdoc_empty_body_is_empty() {
1101 assert!(scan("").is_empty());
1102 }
1103
1104 #[test]
1105 fn scan_jsdoc_no_import_in_body_is_empty() {
1106 assert!(scan(" * @param foo The foo parameter").is_empty());
1107 }
1108
1109 #[test]
1115 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1116 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1119 let imports = scan(body);
1120 assert!(
1121 imports.is_empty(),
1122 "prose import() should not be matched; got: {:?}",
1123 imports
1124 .iter()
1125 .map(|i| i.source.as_str())
1126 .collect::<Vec<_>>()
1127 );
1128 }
1129
1130 #[test]
1131 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1132 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1133 let imports = scan(body);
1134 assert!(
1135 imports.is_empty(),
1136 "object-literal example import() should not be matched; got: {:?}",
1137 imports
1138 .iter()
1139 .map(|i| i.source.as_str())
1140 .collect::<Vec<_>>()
1141 );
1142 }
1143
1144 #[test]
1145 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1146 let imports = scan(" * Use {import('./prose')} as an example string.");
1147 assert!(imports.is_empty());
1148 }
1149
1150 #[test]
1151 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1152 let imports = scan("\n * @example\n * { import('./prose') }\n");
1153 assert!(imports.is_empty());
1154 }
1155
1156 #[test]
1160 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1161 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1162 let imports = scan(body);
1163 assert_eq!(imports.len(), 1, "got: {imports:?}");
1164 assert_eq!(imports[0].source, "./real");
1165 assert_eq!(
1166 imports[0].imported_name,
1167 ImportedName::Named("Foo".to_string())
1168 );
1169 }
1170
1171 #[test]
1172 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1173 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1174 let imports = scan(body);
1175 assert_eq!(imports.len(), 1, "got: {imports:?}");
1176 assert_eq!(imports[0].source, "./real");
1177 assert_eq!(
1178 imports[0].imported_name,
1179 ImportedName::Named("Foo".to_string())
1180 );
1181 }
1182
1183 #[test]
1184 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1185 let body = "\n * @type\n * { import('./real').Foo }\n";
1186 let imports = scan(body);
1187 assert_eq!(imports.len(), 1, "got: {imports:?}");
1188 assert_eq!(imports[0].source, "./real");
1189 assert_eq!(
1190 imports[0].imported_name,
1191 ImportedName::Named("Foo".to_string())
1192 );
1193 }
1194
1195 #[test]
1196 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1197 let imports = scan(" * @satisfies {import('./real').Foo}");
1198 assert_eq!(imports.len(), 1, "got: {imports:?}");
1199 assert_eq!(imports[0].source, "./real");
1200 assert_eq!(
1201 imports[0].imported_name,
1202 ImportedName::Named("Foo".to_string())
1203 );
1204 }
1205
1206 #[test]
1207 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1208 let imports = scan(" * @template {import('./real').Foo} T");
1209 assert_eq!(imports.len(), 1, "got: {imports:?}");
1210 assert_eq!(imports[0].source, "./real");
1211 assert_eq!(
1212 imports[0].imported_name,
1213 ImportedName::Named("Foo".to_string())
1214 );
1215 }
1216
1217 #[test]
1218 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1219 let imports = scan(" * @enum {import('./real').Foo}");
1220 assert_eq!(imports.len(), 1, "got: {imports:?}");
1221 assert_eq!(imports[0].source, "./real");
1222 assert_eq!(
1223 imports[0].imported_name,
1224 ImportedName::Named("Foo".to_string())
1225 );
1226 }
1227
1228 #[test]
1229 fn scan_jsdoc_appends_to_existing_imports() {
1230 let mut imports = vec![ImportInfo {
1231 source: "existing".to_string(),
1232 imported_name: ImportedName::Default,
1233 local_name: "existing".to_string(),
1234 is_type_only: false,
1235 from_style: false,
1236 span: oxc_span::Span::default(),
1237 source_span: oxc_span::Span::default(),
1238 }];
1239 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1240 assert_eq!(imports.len(), 2);
1241 assert_eq!(imports[0].source, "existing");
1242 assert_eq!(imports[1].source, "./new");
1243 }
1244
1245 #[test]
1246 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1247 let imports = scan(" * @type {import('./t').Abc}");
1248 assert_eq!(imports.len(), 1);
1249 assert_eq!(
1250 imports[0].imported_name,
1251 ImportedName::Named("Abc".to_string())
1252 );
1253 }
1254
1255 #[test]
1256 fn scan_jsdoc_empty_member_name_is_skipped() {
1257 let imports = scan(" * @type {import('./x').}");
1258 assert!(imports.is_empty());
1259 }
1260}