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
47}
48
49fn parse_source_to_module_inner(
50 file_id: FileId,
51 path: &Path,
52 source: &str,
53 content_hash: u64,
54 need_complexity: bool,
55) -> ModuleInfo {
56 let source = crate::strip_bom(source);
57 if is_sfc_file(path) {
58 return parse_sfc_to_module(file_id, path, source, content_hash, need_complexity);
59 }
60 if is_astro_file(path) {
61 return parse_astro_to_module(file_id, source, content_hash);
62 }
63 if is_mdx_file(path) {
64 return parse_mdx_to_module(file_id, source, content_hash);
65 }
66 if is_css_file(path) {
67 return parse_css_to_module(file_id, path, source, content_hash);
68 }
69 if is_graphql_file(path) {
70 return parse_graphql_to_module(file_id, source, content_hash);
71 }
72 if is_html_file(path) {
73 return parse_html_to_module_with_complexity(
74 file_id,
75 source,
76 content_hash,
77 need_complexity,
78 );
79 }
80
81 let stripped_glimmer_source = is_glimmer_file(path)
82 .then(|| strip_glimmer_templates(source))
83 .flatten();
84 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
85 let source_type = source_type_for_path(path);
86 let allocator = Allocator::default();
87 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
88
89 let mut parsed_suppressions =
90 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
91
92 let mut extractor = ModuleInfoExtractor::new();
93 extractor.visit_program(&parser_return.program);
94 extractor.resolve_pending_local_export_specifiers();
95
96 let mut template_used_imports =
97 collect_glimmer_template_into_extractor(&mut extractor, path, source);
98
99 let mut semantic_usage = compute_semantic_usage(
100 &parser_return.program,
101 &extractor.imports,
102 &template_used_imports,
103 );
104
105 let line_offsets = fallow_types::extract::compute_line_offsets(source);
106
107 let mut complexity = if need_complexity {
108 crate::complexity::compute_complexity(&parser_return.program, parser_source, &line_offsets)
109 } else {
110 Vec::new()
111 };
112 if need_complexity {
113 append_inline_template_complexity(
114 &mut complexity,
115 &extractor.inline_template_findings,
116 &line_offsets,
117 );
118 }
119
120 let mut flag_uses = crate::flags::extract_flags(
121 &parser_return.program,
122 &line_offsets,
123 &[], &[], false, );
127
128 let total_extracted =
129 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
130 let mut used_retry = false;
131 if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
132 let jsx_type = if source_type.is_typescript() {
133 SourceType::tsx()
134 } else {
135 SourceType::jsx()
136 };
137 let allocator2 = Allocator::default();
138 let retry_return = Parser::new(&allocator2, parser_source, jsx_type).parse();
139 let mut retry_extractor = ModuleInfoExtractor::new();
140 retry_extractor.visit_program(&retry_return.program);
141 retry_extractor.resolve_pending_local_export_specifiers();
142 let retry_total = retry_extractor.exports.len()
143 + retry_extractor.imports.len()
144 + retry_extractor.re_exports.len();
145 if retry_total > total_extracted {
146 template_used_imports =
147 collect_glimmer_template_into_extractor(&mut retry_extractor, path, source);
148 semantic_usage = compute_semantic_usage(
149 &retry_return.program,
150 &retry_extractor.imports,
151 &template_used_imports,
152 );
153 if need_complexity {
154 complexity = crate::complexity::compute_complexity(
155 &retry_return.program,
156 parser_source,
157 &line_offsets,
158 );
159 append_inline_template_complexity(
160 &mut complexity,
161 &retry_extractor.inline_template_findings,
162 &line_offsets,
163 );
164 }
165 flag_uses =
166 crate::flags::extract_flags(&retry_return.program, &line_offsets, &[], &[], false);
167 parsed_suppressions =
168 crate::suppress::parse_suppressions(&retry_return.program.comments, source);
169 apply_jsdoc_visibility_tags(
170 &mut retry_extractor.exports,
171 &retry_return.program.comments,
172 source,
173 );
174 extract_jsdoc_import_types(
175 &mut retry_extractor.imports,
176 &retry_return.program.comments,
177 source,
178 );
179 extractor = retry_extractor;
180 used_retry = true;
181 }
182 }
183
184 if !used_retry {
185 apply_jsdoc_visibility_tags(
186 &mut extractor.exports,
187 &parser_return.program.comments,
188 source,
189 );
190 extract_jsdoc_import_types(
191 &mut extractor.imports,
192 &parser_return.program.comments,
193 source,
194 );
195 }
196
197 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
198 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
199 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
200 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
201 info.auto_import_candidates = semantic_usage.auto_import_candidates;
202 info.line_offsets = line_offsets;
203 info.complexity = complexity;
204 info.flag_uses = flag_uses;
205
206 info
207}
208
209fn collect_glimmer_template_into_extractor(
231 extractor: &mut ModuleInfoExtractor,
232 path: &Path,
233 source: &str,
234) -> rustc_hash::FxHashSet<String> {
235 use rustc_hash::FxHashSet;
236
237 if !is_glimmer_file(path) {
238 return FxHashSet::default();
239 }
240 let template_ranges = crate::glimmer::find_template_ranges(source);
241 if template_ranges.is_empty() {
242 return FxHashSet::default();
243 }
244
245 let imported_bindings: FxHashSet<String> = extractor
246 .imports
247 .iter()
248 .filter(|import| !import.local_name.is_empty())
249 .map(|import| import.local_name.clone())
250 .collect();
251
252 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
253 source,
254 &template_ranges,
255 &imported_bindings,
256 );
257 extractor.member_accesses.extend(usage.member_accesses);
258 usage.used_bindings
259}
260
261fn append_inline_template_complexity(
272 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
273 findings: &[crate::visitor::InlineTemplateFinding],
274 line_offsets: &[u32],
275) {
276 for finding in findings {
277 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
278 &finding.template_source,
279 ) else {
280 continue;
281 };
282 let (line, col) =
283 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
284 fc.line = line;
285 fc.col = col;
286 complexity.push(fc);
287 }
288}
289
290fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
300 if exports.is_empty() || comments.is_empty() {
301 return;
302 }
303
304 let mut tag_offsets: Vec<(u32, VisibilityTag)> = Vec::new();
305 for comment in comments {
306 if comment.is_jsdoc() {
307 let content_span = comment.content_span();
308 let start = content_span.start as usize;
309 let end = (content_span.end as usize).min(source.len());
310 if start < end {
311 let text = &source[start..end];
312 let tag = if has_public_tag(text) {
313 VisibilityTag::Public
314 } else if has_internal_tag(text) {
315 VisibilityTag::Internal
316 } else if has_alpha_tag(text) {
317 VisibilityTag::Alpha
318 } else if has_beta_tag(text) {
319 VisibilityTag::Beta
320 } else if has_expected_unused_tag(text) {
321 VisibilityTag::ExpectedUnused
322 } else {
323 continue;
324 };
325 tag_offsets.push((comment.attached_to, tag));
326 }
327 }
328 }
329
330 if tag_offsets.is_empty() {
331 return;
332 }
333
334 tag_offsets.sort_unstable_by_key(|&(offset, _)| offset);
335
336 for export in exports.iter_mut() {
337 if export.span.start == 0 && export.span.end == 0 {
338 continue;
339 }
340
341 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _)| o) {
342 export.visibility = tag_offsets[idx].1;
343 continue;
344 }
345
346 let idx = tag_offsets.partition_point(|&(o, _)| o <= export.span.start);
347 if idx > 0 {
348 let (offset, tag) = tag_offsets[idx - 1];
349 let offset = offset as usize;
350 let export_start = export.span.start as usize;
351 if offset < export_start && export_start <= source.len() {
352 let between = &source[offset..export_start];
353 if between.starts_with("export") && !between.contains(';') && !between.contains('}')
354 {
355 export.visibility = tag;
356 }
357 }
358 }
359 }
360}
361
362fn has_internal_tag(comment_text: &str) -> bool {
364 for (i, _) in comment_text.match_indices("@internal") {
365 let after = i + "@internal".len();
366 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
367 return true;
368 }
369 }
370 false
371}
372
373fn has_beta_tag(comment_text: &str) -> bool {
375 for (i, _) in comment_text.match_indices("@beta") {
376 let after = i + "@beta".len();
377 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
378 return true;
379 }
380 }
381 false
382}
383
384fn has_alpha_tag(comment_text: &str) -> bool {
386 for (i, _) in comment_text.match_indices("@alpha") {
387 let after = i + "@alpha".len();
388 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
389 return true;
390 }
391 }
392 false
393}
394
395fn has_expected_unused_tag(comment_text: &str) -> bool {
397 for (i, _) in comment_text.match_indices("@expected-unused") {
398 let after = i + "@expected-unused".len();
399 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
400 return true;
401 }
402 }
403 false
404}
405
406const fn is_ident_char(b: u8) -> bool {
408 b.is_ascii_alphanumeric() || b == b'_'
409}
410
411fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
434 if comments.is_empty() {
435 return;
436 }
437
438 for comment in comments {
439 if !comment.is_jsdoc() {
440 continue;
441 }
442 let content_span = comment.content_span();
443 let start = content_span.start as usize;
444 let end = (content_span.end as usize).min(source.len());
445 if start >= end {
446 continue;
447 }
448 scan_jsdoc_imports_in(&source[start..end], imports);
449 }
450}
451
452fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
460 let bytes = body.as_bytes();
461 let mut cursor = 0;
462 while let Some(rel) = body[cursor..].find("import(") {
463 let open = cursor + rel + "import(".len();
464 cursor = open;
465 if open >= bytes.len() {
466 break;
467 }
468 let mut i = open;
469 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
470 i += 1;
471 }
472 if i >= bytes.len() {
473 break;
474 }
475 let quote = bytes[i];
476 if quote != b'\'' && quote != b'"' {
477 continue;
478 }
479 let path_start = i + 1;
480 let Some(rel_close) = body[path_start..].find(quote as char) else {
481 break;
482 };
483 let path_end = path_start + rel_close;
484 let path = &body[path_start..path_end];
485 if path.is_empty() {
486 cursor = path_end + 1;
487 continue;
488 }
489 let mut j = path_end + 1;
490 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
491 j += 1;
492 }
493 if j >= bytes.len() || bytes[j] != b')' {
494 cursor = path_end + 1;
495 continue;
496 }
497 j += 1;
498 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
499 j += 1;
500 }
501 cursor = j;
502 if j >= bytes.len() || bytes[j] != b'.' {
503 imports.push(ImportInfo {
504 source: path.to_string(),
505 imported_name: fallow_types::extract::ImportedName::SideEffect,
506 local_name: String::new(),
507 is_type_only: true,
508 from_style: false,
509 span: oxc_span::Span::default(),
510 source_span: oxc_span::Span::default(),
511 });
512 continue;
513 }
514 j += 1;
515 let name_start = j;
516 while j < bytes.len() && is_ident_char(bytes[j]) {
517 j += 1;
518 }
519 if name_start == j {
520 continue;
521 }
522 let member = &body[name_start..j];
523 cursor = j;
524 imports.push(ImportInfo {
525 source: path.to_string(),
526 imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
527 local_name: String::new(),
528 is_type_only: true,
529 from_style: false,
530 span: oxc_span::Span::default(),
531 source_span: oxc_span::Span::default(),
532 });
533 }
534}
535
536fn has_public_tag(comment_text: &str) -> bool {
538 for (i, _) in comment_text.match_indices("@public") {
539 let after = i + "@public".len();
540 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
541 return true;
542 }
543 }
544 for (i, _) in comment_text.match_indices("@api") {
545 let after = i + "@api".len();
546 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
547 let rest = comment_text[after..].trim_start();
548 if rest.starts_with("public") {
549 let after_public = "public".len();
550 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
551 return true;
552 }
553 }
554 }
555 }
556 false
557}
558
559#[derive(Debug, Default, PartialEq, Eq)]
560pub struct ImportBindingUsage {
561 pub unused: Vec<String>,
562 pub type_referenced: Vec<String>,
563 pub value_referenced: Vec<String>,
564}
565
566#[derive(Debug, Default, PartialEq, Eq)]
567pub struct SemanticUsage {
568 pub import_binding_usage: ImportBindingUsage,
569 pub auto_import_candidates: Vec<String>,
570}
571
572pub fn compute_semantic_usage(
573 program: &Program<'_>,
574 imports: &[ImportInfo],
575 template_used: &rustc_hash::FxHashSet<String>,
576) -> SemanticUsage {
577 use oxc_semantic::SemanticBuilder;
578 use rustc_hash::FxHashSet;
579
580 let semantic_ret = SemanticBuilder::new().build(program);
581 let semantic = semantic_ret.semantic;
582 let scoping = semantic.scoping();
583 let root_scope = scoping.root_scope_id();
584
585 let mut unused = Vec::new();
586 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
587 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
588 for import in imports {
589 if import.local_name.is_empty() {
590 continue;
591 }
592 let name = oxc_str::Ident::from(import.local_name.as_str());
593 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
594 let mut has_references = false;
595 let mut has_type_references = false;
596 let mut has_value_references = false;
597
598 for reference in scoping.get_resolved_references(symbol_id) {
599 has_references = true;
600 has_type_references |= reference.is_type();
601 has_value_references |= reference.is_value();
602 }
603
604 if !has_references {
605 if !template_used.contains(&import.local_name) {
606 unused.push(import.local_name.clone());
607 }
608 continue;
609 }
610
611 if has_type_references {
612 type_referenced_bindings.insert(import.local_name.clone());
613 }
614 if has_value_references {
615 value_referenced_bindings.insert(import.local_name.clone());
616 }
617 }
618 }
619
620 unused.sort_unstable();
621
622 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
623 type_referenced_bindings.sort_unstable();
624
625 let mut value_referenced_bindings: Vec<String> =
626 value_referenced_bindings.into_iter().collect();
627 value_referenced_bindings.sort_unstable();
628
629 SemanticUsage {
630 import_binding_usage: ImportBindingUsage {
631 unused,
632 type_referenced: type_referenced_bindings,
633 value_referenced: value_referenced_bindings,
634 },
635 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
636 }
637}
638
639pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
640 use oxc_semantic::SemanticBuilder;
641
642 let semantic_ret = SemanticBuilder::new().build(program);
643 let semantic = semantic_ret.semantic;
644 compute_auto_import_candidates_from_semantic(semantic.scoping())
645}
646
647fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
648 use rustc_hash::FxHashSet;
649
650 let mut candidates: FxHashSet<String> = FxHashSet::default();
651 for (name, reference_ids) in scoping.root_unresolved_references() {
652 if reference_ids
653 .iter()
654 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
655 {
656 candidates.insert(name.as_str().to_string());
657 }
658 }
659
660 let mut candidates: Vec<String> = candidates.into_iter().collect();
661 candidates.sort_unstable();
662 candidates
663}
664
665pub fn compute_import_binding_usage(
682 program: &Program<'_>,
683 imports: &[ImportInfo],
684 template_used: &rustc_hash::FxHashSet<String>,
685) -> ImportBindingUsage {
686 compute_semantic_usage(program, imports, template_used).import_binding_usage
687}
688
689#[cfg(test)]
690mod tests {
691 use super::{
692 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
693 scan_jsdoc_imports_in,
694 };
695 use fallow_types::discover::FileId;
696 use fallow_types::extract::{ImportInfo, ImportedName};
697 use std::path::Path;
698
699 #[test]
700 fn has_public_tag_matches_bare_tag() {
701 assert!(has_public_tag(" * @public"));
702 }
703
704 #[test]
705 fn has_public_tag_matches_api_public_variant() {
706 assert!(has_public_tag(" * @api public"));
707 }
708
709 #[test]
710 fn has_public_tag_rejects_partial_word() {
711 assert!(!has_public_tag(" * @publicly"));
712 }
713
714 #[test]
715 fn has_public_tag_rejects_at_apipublic() {
716 assert!(!has_public_tag(" * @apipublic"));
717 }
718
719 #[test]
720 fn has_public_tag_rejects_missing_at() {
721 assert!(!has_public_tag(" * public"));
722 }
723
724 #[test]
725 fn has_internal_tag_matches_bare_tag() {
726 assert!(has_internal_tag(" * @internal"));
727 }
728
729 #[test]
730 fn has_internal_tag_rejects_partial_word() {
731 assert!(!has_internal_tag(" * @internalizer"));
732 }
733
734 #[test]
735 fn has_internal_tag_rejects_missing_at() {
736 assert!(!has_internal_tag(" * internal"));
737 }
738
739 #[test]
740 fn has_beta_tag_matches_bare_tag() {
741 assert!(has_beta_tag(" * @beta"));
742 }
743
744 #[test]
745 fn has_beta_tag_rejects_partial_word() {
746 assert!(!has_beta_tag(" * @betaware"));
747 }
748
749 #[test]
750 fn has_beta_tag_rejects_missing_at() {
751 assert!(!has_beta_tag(" * beta"));
752 }
753
754 #[test]
755 fn alpha_tag_standalone() {
756 assert!(has_alpha_tag("@alpha"));
757 }
758
759 #[test]
760 fn alpha_tag_with_text() {
761 assert!(has_alpha_tag("@alpha Some description"));
762 }
763
764 #[test]
765 fn alpha_tag_not_prefix() {
766 assert!(!has_alpha_tag("@alphabet"));
767 }
768
769 #[test]
770 fn has_alpha_tag_rejects_missing_at() {
771 assert!(!has_alpha_tag(" * alpha"));
772 }
773
774 fn scan(body: &str) -> Vec<ImportInfo> {
775 let mut imports = Vec::new();
776 scan_jsdoc_imports_in(body, &mut imports);
777 imports
778 }
779
780 #[test]
781 fn scan_jsdoc_single_import_with_member() {
782 let imports = scan(" * @param foo {import('./types').Foo}");
783 assert_eq!(imports.len(), 1);
784 assert_eq!(imports[0].source, "./types");
785 assert_eq!(
786 imports[0].imported_name,
787 ImportedName::Named("Foo".to_string())
788 );
789 assert!(imports[0].is_type_only);
790 assert!(imports[0].local_name.is_empty());
791 }
792
793 #[test]
794 fn script_auto_import_candidates_capture_zero_import_value_refs() {
795 let info = parse_source_to_module(
796 FileId(0),
797 Path::new("pages/index.ts"),
798 r"
799 useCounter();
800 const price = formatPrice(10);
801 const localOnly = () => null;
802 localOnly();
803 type Local = UseTypeOnly;
804 ",
805 0,
806 false,
807 );
808
809 assert!(
810 info.auto_import_candidates
811 .contains(&"formatPrice".to_string())
812 );
813 assert!(
814 info.auto_import_candidates
815 .contains(&"useCounter".to_string())
816 );
817 assert!(
818 !info
819 .auto_import_candidates
820 .contains(&"UseTypeOnly".to_string())
821 );
822 assert!(
823 !info
824 .auto_import_candidates
825 .contains(&"localOnly".to_string())
826 );
827 }
828
829 #[test]
830 fn script_auto_import_candidates_skip_explicit_imports() {
831 let info = parse_source_to_module(
832 FileId(0),
833 Path::new("pages/index.ts"),
834 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
835 0,
836 false,
837 );
838
839 assert!(
840 !info
841 .auto_import_candidates
842 .contains(&"useCounter".to_string())
843 );
844 assert!(
845 info.auto_import_candidates
846 .contains(&"useOther".to_string())
847 );
848 }
849
850 #[test]
851 fn scan_jsdoc_double_quoted_path() {
852 let imports = scan(r#" * @type {import("./types").Foo}"#);
853 assert_eq!(imports.len(), 1);
854 assert_eq!(imports[0].source, "./types");
855 }
856
857 #[test]
858 fn scan_jsdoc_multiple_imports_in_same_body() {
859 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
860 assert_eq!(imports.len(), 2);
861 assert_eq!(imports[0].source, "./a");
862 assert_eq!(imports[1].source, "./b");
863 }
864
865 #[test]
866 fn scan_jsdoc_union_annotation_captures_both_members() {
867 let imports = scan(" * @type {import('./a').A | import('./b').B}");
868 assert_eq!(imports.len(), 2);
869 assert_eq!(
870 imports[0].imported_name,
871 ImportedName::Named("A".to_string())
872 );
873 assert_eq!(
874 imports[1].imported_name,
875 ImportedName::Named("B".to_string())
876 );
877 }
878
879 #[test]
880 fn scan_jsdoc_nested_member_uses_first_segment() {
881 let imports = scan(" * @type {import('./types').ns.Foo}");
882 assert_eq!(imports.len(), 1);
883 assert_eq!(
884 imports[0].imported_name,
885 ImportedName::Named("ns".to_string())
886 );
887 }
888
889 #[test]
890 fn scan_jsdoc_parent_relative_path() {
891 let imports = scan(" * @type {import('../lib/types.js').Foo}");
892 assert_eq!(imports.len(), 1);
893 assert_eq!(imports[0].source, "../lib/types.js");
894 }
895
896 #[test]
897 fn scan_jsdoc_bare_package_specifier() {
898 let imports = scan(" * @type {import('@scope/pkg').Client}");
899 assert_eq!(imports.len(), 1);
900 assert_eq!(imports[0].source, "@scope/pkg");
901 assert_eq!(
902 imports[0].imported_name,
903 ImportedName::Named("Client".to_string())
904 );
905 }
906
907 #[test]
908 fn scan_jsdoc_without_member_is_side_effect() {
909 let imports = scan(" * @type {import('./types')}");
910 assert_eq!(imports.len(), 1);
911 assert_eq!(imports[0].source, "./types");
912 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
913 assert!(imports[0].is_type_only);
914 }
915
916 #[test]
917 fn scan_jsdoc_empty_path_is_skipped() {
918 let imports = scan(" * @type {import('').Foo}");
919 assert!(imports.is_empty());
920 }
921
922 #[test]
923 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
924 let imports = scan(" * @type {import('./truncated");
925 assert!(imports.is_empty());
926 }
927
928 #[test]
929 fn scan_jsdoc_missing_closing_paren_is_skipped() {
930 let imports = scan(" * @type {import('./types'.Foo}");
931 assert!(imports.is_empty());
932 }
933
934 #[test]
935 fn scan_jsdoc_whitespace_between_paren_and_dot() {
936 let imports = scan(" * @type {import('./types') .Foo}");
937 assert_eq!(imports.len(), 1);
938 assert_eq!(imports[0].source, "./types");
939 assert_eq!(
940 imports[0].imported_name,
941 ImportedName::Named("Foo".to_string())
942 );
943 }
944
945 #[test]
946 fn scan_jsdoc_whitespace_between_paren_and_quote() {
947 let imports = scan(" * @type {import( './types').Foo}");
948 assert_eq!(imports.len(), 1);
949 assert_eq!(imports[0].source, "./types");
950 }
951
952 #[test]
953 fn scan_jsdoc_non_quote_after_paren_skipped() {
954 let imports = scan(" * @type {import(foo).Bar}");
955 assert!(imports.is_empty());
956 }
957
958 #[test]
959 fn scan_jsdoc_ignores_prose_with_import_word() {
960 let imports = scan(" * This is an important note about imports.");
961 assert!(imports.is_empty());
962 }
963
964 #[test]
965 fn scan_jsdoc_utf8_path_works() {
966 let imports = scan(" * @type {import('./héllo').Foo}");
967 assert_eq!(imports.len(), 1);
968 assert_eq!(imports[0].source, "./héllo");
969 }
970
971 #[test]
972 fn scan_jsdoc_empty_body_is_empty() {
973 assert!(scan("").is_empty());
974 }
975
976 #[test]
977 fn scan_jsdoc_no_import_in_body_is_empty() {
978 assert!(scan(" * @param foo The foo parameter").is_empty());
979 }
980
981 #[test]
982 fn scan_jsdoc_appends_to_existing_imports() {
983 let mut imports = vec![ImportInfo {
984 source: "existing".to_string(),
985 imported_name: ImportedName::Default,
986 local_name: "existing".to_string(),
987 is_type_only: false,
988 from_style: false,
989 span: oxc_span::Span::default(),
990 source_span: oxc_span::Span::default(),
991 }];
992 scan_jsdoc_imports_in(" * {import('./new').Foo}", &mut imports);
993 assert_eq!(imports.len(), 2);
994 assert_eq!(imports[0].source, "existing");
995 assert_eq!(imports[1].source, "./new");
996 }
997
998 #[test]
999 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1000 let imports = scan(" * @type {import('./t').Abc}");
1001 assert_eq!(imports.len(), 1);
1002 assert_eq!(
1003 imports[0].imported_name,
1004 ImportedName::Named("Abc".to_string())
1005 );
1006 }
1007
1008 #[test]
1009 fn scan_jsdoc_empty_member_name_is_skipped() {
1010 let imports = scan(" * @type {import('./x').}");
1011 assert!(imports.is_empty());
1012 }
1013}