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) {
435 if comments.is_empty() {
436 return;
437 }
438
439 for comment in comments {
440 if !comment.is_jsdoc() {
441 continue;
442 }
443 let content_span = comment.content_span();
444 let start = content_span.start as usize;
445 let end = (content_span.end as usize).min(source.len());
446 if start >= end {
447 continue;
448 }
449 scan_jsdoc_imports_in(&source[start..end], imports);
450 }
451}
452
453fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
461 let bytes = body.as_bytes();
462 let mut cursor = 0;
463 while let Some(rel) = body[cursor..].find("import(") {
464 let open = cursor + rel + "import(".len();
465 cursor = open;
466 if open >= bytes.len() {
467 break;
468 }
469 let mut i = open;
470 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
471 i += 1;
472 }
473 if i >= bytes.len() {
474 break;
475 }
476 let quote = bytes[i];
477 if quote != b'\'' && quote != b'"' {
478 continue;
479 }
480 let path_start = i + 1;
481 let Some(rel_close) = body[path_start..].find(quote as char) else {
482 break;
483 };
484 let path_end = path_start + rel_close;
485 let path = &body[path_start..path_end];
486 if path.is_empty() {
487 cursor = path_end + 1;
488 continue;
489 }
490 let mut j = path_end + 1;
491 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
492 j += 1;
493 }
494 if j >= bytes.len() || bytes[j] != b')' {
495 cursor = path_end + 1;
496 continue;
497 }
498 j += 1;
499 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
500 j += 1;
501 }
502 cursor = j;
503 if j >= bytes.len() || bytes[j] != b'.' {
504 imports.push(ImportInfo {
505 source: path.to_string(),
506 imported_name: fallow_types::extract::ImportedName::SideEffect,
507 local_name: String::new(),
508 is_type_only: true,
509 from_style: false,
510 span: oxc_span::Span::default(),
511 source_span: oxc_span::Span::default(),
512 });
513 continue;
514 }
515 j += 1;
516 let name_start = j;
517 while j < bytes.len() && is_ident_char(bytes[j]) {
518 j += 1;
519 }
520 if name_start == j {
521 continue;
522 }
523 let member = &body[name_start..j];
524 cursor = j;
525 imports.push(ImportInfo {
526 source: path.to_string(),
527 imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
528 local_name: String::new(),
529 is_type_only: true,
530 from_style: false,
531 span: oxc_span::Span::default(),
532 source_span: oxc_span::Span::default(),
533 });
534 }
535}
536
537fn has_public_tag(comment_text: &str) -> bool {
539 for (i, _) in comment_text.match_indices("@public") {
540 let after = i + "@public".len();
541 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
542 return true;
543 }
544 }
545 for (i, _) in comment_text.match_indices("@api") {
546 let after = i + "@api".len();
547 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
548 let rest = comment_text[after..].trim_start();
549 if rest.starts_with("public") {
550 let after_public = "public".len();
551 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
552 return true;
553 }
554 }
555 }
556 }
557 false
558}
559
560#[derive(Debug, Default, PartialEq, Eq)]
561pub struct ImportBindingUsage {
562 pub unused: Vec<String>,
563 pub type_referenced: Vec<String>,
564 pub value_referenced: Vec<String>,
565}
566
567#[derive(Debug, Default, PartialEq, Eq)]
568pub struct SemanticUsage {
569 pub import_binding_usage: ImportBindingUsage,
570 pub auto_import_candidates: Vec<String>,
571}
572
573pub fn compute_semantic_usage(
574 program: &Program<'_>,
575 imports: &[ImportInfo],
576 template_used: &rustc_hash::FxHashSet<String>,
577) -> SemanticUsage {
578 use oxc_semantic::SemanticBuilder;
579 use rustc_hash::FxHashSet;
580
581 let semantic_ret = SemanticBuilder::new().build(program);
582 let semantic = semantic_ret.semantic;
583 let scoping = semantic.scoping();
584 let root_scope = scoping.root_scope_id();
585
586 let mut unused = Vec::new();
587 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
588 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
589 for import in imports {
590 if import.local_name.is_empty() {
591 continue;
592 }
593 let name = oxc_str::Ident::from(import.local_name.as_str());
594 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
595 let mut has_references = false;
596 let mut has_type_references = false;
597 let mut has_value_references = false;
598
599 for reference in scoping.get_resolved_references(symbol_id) {
600 has_references = true;
601 has_type_references |= reference.is_type();
602 has_value_references |= reference.is_value();
603 }
604
605 if !has_references {
606 if !template_used.contains(&import.local_name) {
607 unused.push(import.local_name.clone());
608 }
609 continue;
610 }
611
612 if has_type_references {
613 type_referenced_bindings.insert(import.local_name.clone());
614 }
615 if has_value_references {
616 value_referenced_bindings.insert(import.local_name.clone());
617 }
618 }
619 }
620
621 unused.sort_unstable();
622
623 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
624 type_referenced_bindings.sort_unstable();
625
626 let mut value_referenced_bindings: Vec<String> =
627 value_referenced_bindings.into_iter().collect();
628 value_referenced_bindings.sort_unstable();
629
630 SemanticUsage {
631 import_binding_usage: ImportBindingUsage {
632 unused,
633 type_referenced: type_referenced_bindings,
634 value_referenced: value_referenced_bindings,
635 },
636 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
637 }
638}
639
640pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
641 use oxc_semantic::SemanticBuilder;
642
643 let semantic_ret = SemanticBuilder::new().build(program);
644 let semantic = semantic_ret.semantic;
645 compute_auto_import_candidates_from_semantic(semantic.scoping())
646}
647
648fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
649 use rustc_hash::FxHashSet;
650
651 let mut candidates: FxHashSet<String> = FxHashSet::default();
652 for (name, reference_ids) in scoping.root_unresolved_references() {
653 if reference_ids
654 .iter()
655 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
656 {
657 candidates.insert(name.as_str().to_string());
658 }
659 }
660
661 let mut candidates: Vec<String> = candidates.into_iter().collect();
662 candidates.sort_unstable();
663 candidates
664}
665
666pub fn compute_import_binding_usage(
683 program: &Program<'_>,
684 imports: &[ImportInfo],
685 template_used: &rustc_hash::FxHashSet<String>,
686) -> ImportBindingUsage {
687 compute_semantic_usage(program, imports, template_used).import_binding_usage
688}
689
690#[cfg(test)]
691mod tests {
692 use super::{
693 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
694 scan_jsdoc_imports_in,
695 };
696 use fallow_types::discover::FileId;
697 use fallow_types::extract::{ImportInfo, ImportedName};
698 use std::path::Path;
699
700 #[test]
701 fn has_public_tag_matches_bare_tag() {
702 assert!(has_public_tag(" * @public"));
703 }
704
705 #[test]
706 fn has_public_tag_matches_api_public_variant() {
707 assert!(has_public_tag(" * @api public"));
708 }
709
710 #[test]
711 fn has_public_tag_rejects_partial_word() {
712 assert!(!has_public_tag(" * @publicly"));
713 }
714
715 #[test]
716 fn has_public_tag_rejects_at_apipublic() {
717 assert!(!has_public_tag(" * @apipublic"));
718 }
719
720 #[test]
721 fn has_public_tag_rejects_missing_at() {
722 assert!(!has_public_tag(" * public"));
723 }
724
725 #[test]
726 fn has_internal_tag_matches_bare_tag() {
727 assert!(has_internal_tag(" * @internal"));
728 }
729
730 #[test]
731 fn has_internal_tag_rejects_partial_word() {
732 assert!(!has_internal_tag(" * @internalizer"));
733 }
734
735 #[test]
736 fn has_internal_tag_rejects_missing_at() {
737 assert!(!has_internal_tag(" * internal"));
738 }
739
740 #[test]
741 fn has_beta_tag_matches_bare_tag() {
742 assert!(has_beta_tag(" * @beta"));
743 }
744
745 #[test]
746 fn has_beta_tag_rejects_partial_word() {
747 assert!(!has_beta_tag(" * @betaware"));
748 }
749
750 #[test]
751 fn has_beta_tag_rejects_missing_at() {
752 assert!(!has_beta_tag(" * beta"));
753 }
754
755 #[test]
756 fn alpha_tag_standalone() {
757 assert!(has_alpha_tag("@alpha"));
758 }
759
760 #[test]
761 fn alpha_tag_with_text() {
762 assert!(has_alpha_tag("@alpha Some description"));
763 }
764
765 #[test]
766 fn alpha_tag_not_prefix() {
767 assert!(!has_alpha_tag("@alphabet"));
768 }
769
770 #[test]
771 fn has_alpha_tag_rejects_missing_at() {
772 assert!(!has_alpha_tag(" * alpha"));
773 }
774
775 fn scan(body: &str) -> Vec<ImportInfo> {
776 let mut imports = Vec::new();
777 scan_jsdoc_imports_in(body, &mut imports);
778 imports
779 }
780
781 #[test]
782 fn scan_jsdoc_single_import_with_member() {
783 let imports = scan(" * @param foo {import('./types').Foo}");
784 assert_eq!(imports.len(), 1);
785 assert_eq!(imports[0].source, "./types");
786 assert_eq!(
787 imports[0].imported_name,
788 ImportedName::Named("Foo".to_string())
789 );
790 assert!(imports[0].is_type_only);
791 assert!(imports[0].local_name.is_empty());
792 }
793
794 #[test]
795 fn script_auto_import_candidates_capture_zero_import_value_refs() {
796 let info = parse_source_to_module(
797 FileId(0),
798 Path::new("pages/index.ts"),
799 r"
800 useCounter();
801 const price = formatPrice(10);
802 const localOnly = () => null;
803 localOnly();
804 type Local = UseTypeOnly;
805 ",
806 0,
807 false,
808 );
809
810 assert!(
811 info.auto_import_candidates
812 .contains(&"formatPrice".to_string())
813 );
814 assert!(
815 info.auto_import_candidates
816 .contains(&"useCounter".to_string())
817 );
818 assert!(
819 !info
820 .auto_import_candidates
821 .contains(&"UseTypeOnly".to_string())
822 );
823 assert!(
824 !info
825 .auto_import_candidates
826 .contains(&"localOnly".to_string())
827 );
828 }
829
830 #[test]
831 fn script_auto_import_candidates_skip_explicit_imports() {
832 let info = parse_source_to_module(
833 FileId(0),
834 Path::new("pages/index.ts"),
835 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
836 0,
837 false,
838 );
839
840 assert!(
841 !info
842 .auto_import_candidates
843 .contains(&"useCounter".to_string())
844 );
845 assert!(
846 info.auto_import_candidates
847 .contains(&"useOther".to_string())
848 );
849 }
850
851 #[test]
852 fn scan_jsdoc_double_quoted_path() {
853 let imports = scan(r#" * @type {import("./types").Foo}"#);
854 assert_eq!(imports.len(), 1);
855 assert_eq!(imports[0].source, "./types");
856 }
857
858 #[test]
859 fn scan_jsdoc_multiple_imports_in_same_body() {
860 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
861 assert_eq!(imports.len(), 2);
862 assert_eq!(imports[0].source, "./a");
863 assert_eq!(imports[1].source, "./b");
864 }
865
866 #[test]
867 fn scan_jsdoc_union_annotation_captures_both_members() {
868 let imports = scan(" * @type {import('./a').A | import('./b').B}");
869 assert_eq!(imports.len(), 2);
870 assert_eq!(
871 imports[0].imported_name,
872 ImportedName::Named("A".to_string())
873 );
874 assert_eq!(
875 imports[1].imported_name,
876 ImportedName::Named("B".to_string())
877 );
878 }
879
880 #[test]
881 fn scan_jsdoc_nested_member_uses_first_segment() {
882 let imports = scan(" * @type {import('./types').ns.Foo}");
883 assert_eq!(imports.len(), 1);
884 assert_eq!(
885 imports[0].imported_name,
886 ImportedName::Named("ns".to_string())
887 );
888 }
889
890 #[test]
891 fn scan_jsdoc_parent_relative_path() {
892 let imports = scan(" * @type {import('../lib/types.js').Foo}");
893 assert_eq!(imports.len(), 1);
894 assert_eq!(imports[0].source, "../lib/types.js");
895 }
896
897 #[test]
898 fn scan_jsdoc_bare_package_specifier() {
899 let imports = scan(" * @type {import('@scope/pkg').Client}");
900 assert_eq!(imports.len(), 1);
901 assert_eq!(imports[0].source, "@scope/pkg");
902 assert_eq!(
903 imports[0].imported_name,
904 ImportedName::Named("Client".to_string())
905 );
906 }
907
908 #[test]
909 fn scan_jsdoc_without_member_is_side_effect() {
910 let imports = scan(" * @type {import('./types')}");
911 assert_eq!(imports.len(), 1);
912 assert_eq!(imports[0].source, "./types");
913 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
914 assert!(imports[0].is_type_only);
915 }
916
917 #[test]
918 fn scan_jsdoc_empty_path_is_skipped() {
919 let imports = scan(" * @type {import('').Foo}");
920 assert!(imports.is_empty());
921 }
922
923 #[test]
924 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
925 let imports = scan(" * @type {import('./truncated");
926 assert!(imports.is_empty());
927 }
928
929 #[test]
930 fn scan_jsdoc_missing_closing_paren_is_skipped() {
931 let imports = scan(" * @type {import('./types'.Foo}");
932 assert!(imports.is_empty());
933 }
934
935 #[test]
936 fn scan_jsdoc_whitespace_between_paren_and_dot() {
937 let imports = scan(" * @type {import('./types') .Foo}");
938 assert_eq!(imports.len(), 1);
939 assert_eq!(imports[0].source, "./types");
940 assert_eq!(
941 imports[0].imported_name,
942 ImportedName::Named("Foo".to_string())
943 );
944 }
945
946 #[test]
947 fn scan_jsdoc_whitespace_between_paren_and_quote() {
948 let imports = scan(" * @type {import( './types').Foo}");
949 assert_eq!(imports.len(), 1);
950 assert_eq!(imports[0].source, "./types");
951 }
952
953 #[test]
954 fn scan_jsdoc_non_quote_after_paren_skipped() {
955 let imports = scan(" * @type {import(foo).Bar}");
956 assert!(imports.is_empty());
957 }
958
959 #[test]
960 fn scan_jsdoc_ignores_prose_with_import_word() {
961 let imports = scan(" * This is an important note about imports.");
962 assert!(imports.is_empty());
963 }
964
965 #[test]
966 fn scan_jsdoc_utf8_path_works() {
967 let imports = scan(" * @type {import('./héllo').Foo}");
968 assert_eq!(imports.len(), 1);
969 assert_eq!(imports[0].source, "./héllo");
970 }
971
972 #[test]
973 fn scan_jsdoc_empty_body_is_empty() {
974 assert!(scan("").is_empty());
975 }
976
977 #[test]
978 fn scan_jsdoc_no_import_in_body_is_empty() {
979 assert!(scan(" * @param foo The foo parameter").is_empty());
980 }
981
982 #[test]
983 fn scan_jsdoc_appends_to_existing_imports() {
984 let mut imports = vec![ImportInfo {
985 source: "existing".to_string(),
986 imported_name: ImportedName::Default,
987 local_name: "existing".to_string(),
988 is_type_only: false,
989 from_style: false,
990 span: oxc_span::Span::default(),
991 source_span: oxc_span::Span::default(),
992 }];
993 scan_jsdoc_imports_in(" * {import('./new').Foo}", &mut imports);
994 assert_eq!(imports.len(), 2);
995 assert_eq!(imports[0].source, "existing");
996 assert_eq!(imports[1].source, "./new");
997 }
998
999 #[test]
1000 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1001 let imports = scan(" * @type {import('./t').Abc}");
1002 assert_eq!(imports.len(), 1);
1003 assert_eq!(
1004 imports[0].imported_name,
1005 ImportedName::Named("Abc".to_string())
1006 );
1007 }
1008
1009 #[test]
1010 fn scan_jsdoc_empty_member_name_is_skipped() {
1011 let imports = scan(" * @type {import('./x').}");
1012 assert!(imports.is_empty());
1013 }
1014}