1use std::path::Path;
2
3use oxc_allocator::Allocator;
4use oxc_ast::{
5 AstKind,
6 ast::{Comment, Program},
7};
8use oxc_ast_visit::Visit;
9use oxc_parser::Parser;
10use oxc_span::{SourceType, Span};
11
12use crate::ExportInfo;
13use crate::ModuleInfo;
14use crate::astro::{is_astro_file, parse_astro_to_module};
15use crate::css::{is_css_file, parse_css_to_module};
16use crate::glimmer::{is_glimmer_file, strip_glimmer_templates};
17use crate::graphql::{is_graphql_file, parse_graphql_to_module};
18use crate::html::{is_html_file, parse_html_to_module_with_complexity};
19use crate::mdx::{is_mdx_file, parse_mdx_to_module};
20use crate::sfc::{is_sfc_file, parse_sfc_to_module};
21use crate::visitor::{ModuleInfoExtractor, RouteLoadHarvestMode};
22use fallow_types::discover::FileId;
23use fallow_types::extract::{FlagUse, FunctionComplexity, ImportInfo, ImportedName, VisibilityTag};
24
25struct JsxRetryParse {
26 extractor: ModuleInfoExtractor,
27 semantic_usage: SemanticUsage,
28 complexity: Vec<FunctionComplexity>,
29 flag_uses: Vec<FlagUse>,
30 parsed_suppressions: crate::suppress::ParsedSuppressions,
31}
32
33fn source_type_for_path(path: &Path) -> SourceType {
34 match path.extension().and_then(|ext| ext.to_str()) {
35 Some("gts") => SourceType::ts(),
36 Some("gjs") => SourceType::mjs(),
37 _ => SourceType::from_path(path).unwrap_or_default(),
38 }
39}
40
41pub fn parse_source_to_module(
48 file_id: FileId,
49 path: &Path,
50 source: &str,
51 content_hash: u64,
52 need_complexity: bool,
53) -> ModuleInfo {
54 let mut module =
55 parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
56 module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
57 module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
58 if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
62 module.load_return_keys = Vec::new();
63 module.has_unharvestable_load = false;
64 }
65 module
66}
67
68fn is_sveltekit_page_load_file(path: &Path) -> bool {
73 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
74 return false;
75 };
76 matches!(
77 name,
78 "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
79 )
80}
81
82fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
83 if is_sveltekit_page_load_file(path) {
84 return RouteLoadHarvestMode::SvelteKitPage;
85 }
86 if is_conventional_route_loader_file(path) {
87 return RouteLoadHarvestMode::ConventionalRoute;
88 }
89 RouteLoadHarvestMode::None
90}
91
92fn is_conventional_route_loader_file(path: &Path) -> bool {
93 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
94 return false;
95 };
96 if name.starts_with('+') {
97 return false;
98 }
99 if !matches!(
100 path.extension().and_then(|ext| ext.to_str()),
101 Some("ts" | "tsx" | "js" | "jsx")
102 ) {
103 return false;
104 }
105 if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
106 && path
107 .parent()
108 .and_then(|parent| parent.file_name())
109 .and_then(|part| part.to_str())
110 .is_some_and(|part| matches!(part, "app" | "src"))
111 {
112 return true;
113 }
114 path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
115}
116
117fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
118 let mut previous = None;
119 for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
120 if previous == Some(app_dir) && part == "routes" {
121 return true;
122 }
123 previous = Some(part);
124 }
125 false
126}
127
128fn parse_source_to_module_inner(
129 file_id: FileId,
130 path: &Path,
131 source: &str,
132 content_hash: u64,
133 need_complexity: bool,
134) -> ModuleInfo {
135 let source = crate::strip_bom(source);
136 if let Some(module) =
137 parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
138 {
139 return module;
140 }
141
142 let stripped_glimmer_source = is_glimmer_file(path)
143 .then(|| strip_glimmer_templates(source))
144 .flatten();
145 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
146 let source_type = source_type_for_path(path);
147 let allocator = Allocator::default();
148 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
149
150 let mut parsed_suppressions =
151 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
152
153 let (mut extractor, mut semantic_usage) =
154 build_primary_extractor(&parser_return.program, path, source, source_type);
155
156 let line_offsets = fallow_types::extract::compute_line_offsets(source);
157
158 let (mut complexity, mut flag_uses) = compute_primary_complexity_and_flags(
159 &parser_return.program,
160 parser_source,
161 &extractor.inline_template_findings,
162 &line_offsets,
163 need_complexity,
164 );
165
166 apply_jsx_retry_or_jsdoc(
167 &JsxRetryOrJsdocInput {
168 path,
169 parser_source,
170 source_type,
171 need_complexity,
172 line_offsets: &line_offsets,
173 comments: &parser_return.program.comments,
174 source,
175 },
176 &mut ParseOutputs {
177 extractor: &mut extractor,
178 semantic_usage: &mut semantic_usage,
179 complexity: &mut complexity,
180 flag_uses: &mut flag_uses,
181 parsed_suppressions: &mut parsed_suppressions,
182 },
183 );
184
185 assemble_module_info(ModuleAssemblyInput {
186 extractor,
187 file_id,
188 content_hash,
189 parsed_suppressions,
190 semantic_usage,
191 line_offsets,
192 complexity,
193 flag_uses,
194 })
195}
196
197struct JsxRetryOrJsdocInput<'a> {
199 path: &'a Path,
200 parser_source: &'a str,
201 source_type: SourceType,
202 need_complexity: bool,
203 line_offsets: &'a [u32],
204 comments: &'a [Comment],
205 source: &'a str,
206}
207
208struct ModuleAssemblyInput {
209 extractor: ModuleInfoExtractor,
210 file_id: FileId,
211 content_hash: u64,
212 parsed_suppressions: crate::suppress::ParsedSuppressions,
213 semantic_usage: SemanticUsage,
214 line_offsets: Vec<u32>,
215 complexity: Vec<FunctionComplexity>,
216 flag_uses: Vec<FlagUse>,
217}
218
219fn build_primary_extractor(
222 program: &Program<'_>,
223 path: &Path,
224 source: &str,
225 source_type: SourceType,
226) -> (ModuleInfoExtractor, SemanticUsage) {
227 let mut extractor = ModuleInfoExtractor::new();
228 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
229 extractor.jsx_capable = source_type.is_jsx();
233 extractor.visit_program(program);
234 extractor.resolve_pending_local_export_specifiers();
235
236 let template_used_imports =
237 collect_glimmer_template_into_extractor(&mut extractor, path, source);
238 let semantic_usage =
239 compute_semantic_usage(program, &extractor.imports, &template_used_imports);
240 extractor.resolve_vitest_mock_operations(&semantic_usage.vitest_vi_reference_spans);
241 (extractor, semantic_usage)
242}
243
244fn compute_primary_complexity_and_flags(
247 program: &Program<'_>,
248 parser_source: &str,
249 inline_template_findings: &[crate::visitor::InlineTemplateFinding],
250 line_offsets: &[u32],
251 need_complexity: bool,
252) -> (Vec<FunctionComplexity>, Vec<FlagUse>) {
253 let mut complexity = if need_complexity {
254 crate::complexity::compute_complexity(program, parser_source, line_offsets)
255 } else {
256 Vec::new()
257 };
258 if need_complexity {
259 append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
260 }
261
262 let flag_uses = crate::flags::extract_flags(
263 program,
264 line_offsets,
265 &[], &[], false, );
269 (complexity, flag_uses)
270}
271
272struct ParseOutputs<'a> {
274 extractor: &'a mut ModuleInfoExtractor,
275 semantic_usage: &'a mut SemanticUsage,
276 complexity: &'a mut Vec<FunctionComplexity>,
277 flag_uses: &'a mut Vec<FlagUse>,
278 parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
279}
280
281fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
285 let retry_input = JsxRetryInput {
286 path: input.path,
287 source: input.source,
288 parser_source: input.parser_source,
289 source_type: input.source_type,
290 total_extracted: outputs.extractor.exports.len()
291 + outputs.extractor.imports.len()
292 + outputs.extractor.re_exports.len(),
293 need_complexity: input.need_complexity,
294 line_offsets: input.line_offsets,
295 };
296 let Some(retry) = parse_with_jsx_retry(&retry_input) else {
297 apply_jsdoc_tags_to_extractor(&mut *outputs.extractor, input.comments, input.source);
298 return;
299 };
300 *outputs.extractor = retry.extractor;
301 *outputs.semantic_usage = retry.semantic_usage;
302 *outputs.complexity = retry.complexity;
303 *outputs.flag_uses = retry.flag_uses;
304 *outputs.parsed_suppressions = retry.parsed_suppressions;
305}
306
307fn apply_jsdoc_tags_to_extractor(
310 extractor: &mut ModuleInfoExtractor,
311 comments: &[Comment],
312 source: &str,
313) {
314 apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source);
315 extract_jsdoc_import_types(&mut extractor.imports, comments, source);
316}
317
318fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
321 let ModuleAssemblyInput {
322 extractor,
323 file_id,
324 content_hash,
325 parsed_suppressions,
326 semantic_usage,
327 line_offsets,
328 complexity,
329 flag_uses,
330 } = input;
331 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
332 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
333 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
334 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
335 info.auto_import_candidates = semantic_usage.auto_import_candidates;
336 info.line_offsets = line_offsets;
337 info.complexity = complexity;
338 info.flag_uses = flag_uses;
339 info
340}
341
342struct JsxRetryInput<'a> {
343 path: &'a Path,
344 source: &'a str,
345 parser_source: &'a str,
346 source_type: SourceType,
347 total_extracted: usize,
348 need_complexity: bool,
349 line_offsets: &'a [u32],
350}
351
352fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
353 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
354 return None;
355 }
356
357 let jsx_type = if input.source_type.is_typescript() {
358 SourceType::tsx()
359 } else {
360 SourceType::jsx()
361 };
362 let allocator = Allocator::default();
363 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
364 let mut extractor = ModuleInfoExtractor::new();
365 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
366 extractor.jsx_capable = true;
369 extractor.visit_program(&retry_return.program);
370 extractor.resolve_pending_local_export_specifiers();
371 let retry_total =
372 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
373 if retry_total <= input.total_extracted {
374 return None;
375 }
376
377 let template_used_imports =
378 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
379 let semantic_usage = compute_semantic_usage(
380 &retry_return.program,
381 &extractor.imports,
382 &template_used_imports,
383 );
384 extractor.resolve_vitest_mock_operations(&semantic_usage.vitest_vi_reference_spans);
385 let complexity = retry_complexity(
386 input.need_complexity,
387 &retry_return.program,
388 input.parser_source,
389 input.line_offsets,
390 &extractor,
391 );
392 let flag_uses =
393 crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
394 let parsed_suppressions =
395 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
396 apply_jsdoc_visibility_tags(
397 &mut extractor.exports,
398 &retry_return.program.comments,
399 input.source,
400 );
401 extract_jsdoc_import_types(
402 &mut extractor.imports,
403 &retry_return.program.comments,
404 input.source,
405 );
406 Some(JsxRetryParse {
407 extractor,
408 semantic_usage,
409 complexity,
410 flag_uses,
411 parsed_suppressions,
412 })
413}
414
415fn retry_complexity(
416 need_complexity: bool,
417 program: &Program<'_>,
418 parser_source: &str,
419 line_offsets: &[u32],
420 extractor: &ModuleInfoExtractor,
421) -> Vec<FunctionComplexity> {
422 if !need_complexity {
423 return Vec::new();
424 }
425 let mut complexity =
426 crate::complexity::compute_complexity(program, parser_source, line_offsets);
427 append_inline_template_complexity(
428 &mut complexity,
429 &extractor.inline_template_findings,
430 line_offsets,
431 );
432 complexity
433}
434
435fn parse_non_js_source_to_module(
436 file_id: FileId,
437 path: &Path,
438 source: &str,
439 content_hash: u64,
440 need_complexity: bool,
441) -> Option<ModuleInfo> {
442 if is_sfc_file(path) {
443 return Some(parse_sfc_to_module(
444 file_id,
445 path,
446 source,
447 content_hash,
448 need_complexity,
449 ));
450 }
451 if is_astro_file(path) {
452 return Some(parse_astro_to_module(
453 file_id,
454 source,
455 content_hash,
456 need_complexity,
457 ));
458 }
459 if is_mdx_file(path) {
460 return Some(parse_mdx_to_module(file_id, source, content_hash));
461 }
462 if is_css_file(path) {
463 return Some(parse_css_to_module(file_id, path, source, content_hash));
464 }
465 if is_graphql_file(path) {
466 return Some(parse_graphql_to_module(file_id, source, content_hash));
467 }
468 if is_html_file(path) {
469 return Some(parse_html_to_module_with_complexity(
470 file_id,
471 source,
472 content_hash,
473 need_complexity,
474 ));
475 }
476 None
477}
478
479fn collect_glimmer_template_into_extractor(
501 extractor: &mut ModuleInfoExtractor,
502 path: &Path,
503 source: &str,
504) -> rustc_hash::FxHashSet<String> {
505 use rustc_hash::FxHashSet;
506
507 if !is_glimmer_file(path) {
508 return FxHashSet::default();
509 }
510 let template_ranges = crate::glimmer::find_template_ranges(source);
511 if template_ranges.is_empty() {
512 return FxHashSet::default();
513 }
514
515 let imported_bindings: FxHashSet<String> = extractor
516 .imports
517 .iter()
518 .filter(|import| !import.local_name.is_empty())
519 .map(|import| import.local_name.clone())
520 .collect();
521
522 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
523 source,
524 &template_ranges,
525 &imported_bindings,
526 );
527 extractor.member_accesses.extend(usage.member_accesses);
528 usage.used_bindings
529}
530
531fn append_inline_template_complexity(
542 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
543 findings: &[crate::visitor::InlineTemplateFinding],
544 line_offsets: &[u32],
545) {
546 for finding in findings {
547 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
548 &finding.template_source,
549 ) else {
550 continue;
551 };
552 let (line, col) =
553 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
554 fc.line = line;
555 fc.col = col;
556 complexity.push(fc);
557 }
558}
559
560fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
570 if exports.is_empty() || comments.is_empty() {
571 return;
572 }
573
574 let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
575 if tag_offsets.is_empty() {
576 return;
577 }
578 tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
579
580 for export in exports.iter_mut() {
581 apply_visibility_tag_to_export(export, &tag_offsets, source);
582 }
583}
584
585fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
588 if has_public_tag(text) {
589 Some((VisibilityTag::Public, None))
590 } else if has_internal_tag(text) {
591 Some((VisibilityTag::Internal, None))
592 } else if has_alpha_tag(text) {
593 Some((VisibilityTag::Alpha, None))
594 } else if has_beta_tag(text) {
595 Some((VisibilityTag::Beta, None))
596 } else {
597 let (has_expected_unused, reason) = expected_unused_tag(text);
598 has_expected_unused.then_some((VisibilityTag::ExpectedUnused, reason))
599 }
600}
601
602fn collect_jsdoc_tag_offsets(
605 comments: &[Comment],
606 source: &str,
607) -> Vec<(u32, VisibilityTag, Option<String>)> {
608 let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
609 for comment in comments {
610 if !comment.is_jsdoc() {
611 continue;
612 }
613 let content_span = comment.content_span();
614 let start = content_span.start as usize;
615 let end = (content_span.end as usize).min(source.len());
616 if start >= end {
617 continue;
618 }
619 if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
620 tag_offsets.push((comment.attached_to, tag, reason));
621 }
622 }
623 tag_offsets
624}
625
626fn apply_visibility_tag_to_export(
630 export: &mut ExportInfo,
631 tag_offsets: &[(u32, VisibilityTag, Option<String>)],
632 source: &str,
633) {
634 if export.span.start == 0 && export.span.end == 0 {
635 return;
636 }
637
638 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
639 export.visibility = tag_offsets[idx].1;
640 export
641 .expected_unused_reason
642 .clone_from(&tag_offsets[idx].2);
643 return;
644 }
645
646 let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
647 if idx > 0 {
648 let (offset, tag, ref reason) = tag_offsets[idx - 1];
649 let offset = offset as usize;
650 let export_start = export.span.start as usize;
651 if offset < export_start && export_start <= source.len() {
652 let between = &source[offset..export_start];
653 if between.starts_with("export") && !between.contains(';') && !between.contains('}') {
654 export.visibility = tag;
655 export.expected_unused_reason.clone_from(reason);
656 }
657 }
658 }
659}
660
661fn has_internal_tag(comment_text: &str) -> bool {
663 for (i, _) in comment_text.match_indices("@internal") {
664 let after = i + "@internal".len();
665 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
666 return true;
667 }
668 }
669 false
670}
671
672fn has_beta_tag(comment_text: &str) -> bool {
674 for (i, _) in comment_text.match_indices("@beta") {
675 let after = i + "@beta".len();
676 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
677 return true;
678 }
679 }
680 false
681}
682
683fn has_alpha_tag(comment_text: &str) -> bool {
685 for (i, _) in comment_text.match_indices("@alpha") {
686 let after = i + "@alpha".len();
687 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
688 return true;
689 }
690 }
691 false
692}
693
694fn split_jsdoc_reason(rest: &str) -> Option<String> {
695 for (idx, _) in rest.match_indices("--") {
696 let before_ok = idx == 0
697 || rest[..idx]
698 .chars()
699 .next_back()
700 .is_some_and(char::is_whitespace);
701 let after_idx = idx + 2;
702 let after_ok = after_idx == rest.len()
703 || rest[after_idx..]
704 .chars()
705 .next()
706 .is_some_and(char::is_whitespace);
707 if before_ok && after_ok {
708 let reason = rest[after_idx..].trim();
709 return if reason.is_empty() {
710 None
711 } else {
712 Some(reason.to_string())
713 };
714 }
715 }
716
717 None
718}
719
720fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
722 for (i, _) in comment_text.match_indices("@expected-unused") {
723 let after = i + "@expected-unused".len();
724 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
725 return (true, split_jsdoc_reason(&comment_text[after..]));
726 }
727 }
728 (false, None)
729}
730
731const fn is_ident_char(b: u8) -> bool {
733 b.is_ascii_alphanumeric() || b == b'_'
734}
735
736fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
760 if comments.is_empty() {
761 return;
762 }
763
764 for comment in comments {
765 if !comment.is_jsdoc() {
766 continue;
767 }
768 let content_span = comment.content_span();
769 let start = content_span.start as usize;
770 let end = (content_span.end as usize).min(source.len());
771 if start >= end {
772 continue;
773 }
774 scan_jsdoc_imports_in(&source[start..end], imports);
775 }
776}
777
778fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
786 let bytes = body.as_bytes();
787 let mut cursor = 0;
788 let mut brace_stack: Vec<usize> = Vec::new();
795 let mut scanned = 0;
796 while let Some(rel) = body[cursor..].find("import(") {
797 let import_pos = cursor + rel;
798 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
799 if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
800 cursor = import_pos + "import(".len();
801 continue;
802 }
803 let open = import_pos + "import(".len();
804 match locate_jsdoc_import_path(body, bytes, open) {
805 JsdocImportScan::Stop => break,
806 JsdocImportScan::Skip(next) => {
807 cursor = next;
808 }
809 JsdocImportScan::Found { path, after_paren } => {
810 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
811 }
812 }
813 }
814}
815
816enum JsdocImportScan<'a> {
819 Stop,
821 Skip(usize),
823 Found { path: &'a str, after_paren: usize },
825}
826
827fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
830 if open >= bytes.len() {
831 return JsdocImportScan::Stop;
832 }
833 let mut i = open;
834 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
835 i += 1;
836 }
837 if i >= bytes.len() {
838 return JsdocImportScan::Stop;
839 }
840 let quote = bytes[i];
841 if quote != b'\'' && quote != b'"' {
842 return JsdocImportScan::Skip(open);
843 }
844 let path_start = i + 1;
845 let Some(rel_close) = body[path_start..].find(quote as char) else {
846 return JsdocImportScan::Stop;
847 };
848 let path_end = path_start + rel_close;
849 let path = &body[path_start..path_end];
850 if path.is_empty() {
851 return JsdocImportScan::Skip(path_end + 1);
852 }
853 let mut j = path_end + 1;
854 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
855 j += 1;
856 }
857 if j >= bytes.len() || bytes[j] != b')' {
858 return JsdocImportScan::Skip(path_end + 1);
859 }
860 j += 1;
861 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
862 j += 1;
863 }
864 JsdocImportScan::Found {
865 path,
866 after_paren: j,
867 }
868}
869
870fn resolve_jsdoc_import(
873 body: &str,
874 bytes: &[u8],
875 after_paren: usize,
876 path: &str,
877 imports: &mut Vec<ImportInfo>,
878) -> usize {
879 let mut j = after_paren;
880 if j >= bytes.len() || bytes[j] != b'.' {
881 imports.push(jsdoc_type_import(
882 path,
883 fallow_types::extract::ImportedName::SideEffect,
884 ));
885 return after_paren;
886 }
887 j += 1;
888 let name_start = j;
889 while j < bytes.len() && is_ident_char(bytes[j]) {
890 j += 1;
891 }
892 if name_start == j {
893 return after_paren;
896 }
897 let member = &body[name_start..j];
898 imports.push(jsdoc_type_import(
899 path,
900 fallow_types::extract::ImportedName::Named(member.to_string()),
901 ));
902 j
903}
904
905fn jsdoc_type_import(
908 source: &str,
909 imported_name: fallow_types::extract::ImportedName,
910) -> ImportInfo {
911 ImportInfo {
912 source: source.to_string(),
913 imported_name,
914 local_name: String::new(),
915 is_type_only: true,
916 from_style: false,
917 span: oxc_span::Span::default(),
918 source_span: oxc_span::Span::default(),
919 }
920}
921
922fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
928 let Some(open_brace) = open_brace else {
929 return false;
930 };
931
932 let prefix = line_prefix_before(body, open_brace);
933 if jsdoc_line_prefix_has_type_tag(prefix) {
934 return true;
935 }
936
937 strip_jsdoc_line_prefix(prefix).is_empty()
938 && preceding_jsdoc_line_has_type_tag(body, open_brace)
939 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
940}
941
942fn advance_jsdoc_brace_stack(
952 body: &[u8],
953 stack: &mut Vec<usize>,
954 scanned: &mut usize,
955 up_to: usize,
956) {
957 let up_to = up_to.min(body.len());
958 while *scanned < up_to {
959 match body[*scanned] {
960 b'{' => stack.push(*scanned),
961 b'}' => {
962 stack.pop();
963 }
964 _ => {}
965 }
966 *scanned += 1;
967 }
968}
969
970fn line_prefix_before(body: &[u8], pos: usize) -> &str {
971 let start = body[..pos]
972 .iter()
973 .rposition(|&b| b == b'\n')
974 .map_or(0, |idx| idx + 1);
975 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
976}
977
978fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
979 let trimmed = prefix.trim_start();
980 trimmed
981 .strip_prefix('*')
982 .map_or(trimmed, |rest| rest.trim_start())
983}
984
985fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
986 const TYPE_TAGS: [&str; 17] = [
987 "@arg",
988 "@argument",
989 "@augments",
990 "@callback",
991 "@enum",
992 "@extends",
993 "@implements",
994 "@param",
995 "@property",
996 "@prop",
997 "@return",
998 "@returns",
999 "@satisfies",
1000 "@template",
1001 "@this",
1002 "@type",
1003 "@typedef",
1004 ];
1005
1006 let prefix = strip_jsdoc_line_prefix(prefix);
1007 TYPE_TAGS
1008 .iter()
1009 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
1010}
1011
1012fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
1013 for (idx, _) in text.match_indices(tag) {
1014 let after = idx + tag.len();
1015 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
1016 return true;
1017 }
1018 }
1019 false
1020}
1021
1022fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1023 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1024 return false;
1025 };
1026
1027 let line_start = body[..line_end]
1028 .iter()
1029 .rposition(|&b| b == b'\n')
1030 .map_or(0, |idx| idx + 1);
1031
1032 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1033}
1034
1035fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1036 let mut at_line_start = true;
1037 let mut i = start.min(body.len());
1038 let end = end.min(body.len());
1039 while i < end {
1040 match body[i] {
1041 b'\n' => {
1042 at_line_start = true;
1043 i += 1;
1044 }
1045 b'\r' | b'\t' | b' ' => {
1046 i += 1;
1047 }
1048 b'*' if at_line_start => {
1049 at_line_start = false;
1050 i += 1;
1051 }
1052 _ => return false,
1053 }
1054 }
1055 true
1056}
1057
1058fn has_public_tag(comment_text: &str) -> bool {
1060 for (i, _) in comment_text.match_indices("@public") {
1061 let after = i + "@public".len();
1062 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1063 return true;
1064 }
1065 }
1066 for (i, _) in comment_text.match_indices("@api") {
1067 let after = i + "@api".len();
1068 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1069 let rest = comment_text[after..].trim_start();
1070 if rest.starts_with("public") {
1071 let after_public = "public".len();
1072 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1073 return true;
1074 }
1075 }
1076 }
1077 }
1078 false
1079}
1080
1081#[derive(Debug, Default, PartialEq, Eq)]
1082pub struct ImportBindingUsage {
1083 pub unused: Vec<String>,
1084 pub type_referenced: Vec<String>,
1085 pub value_referenced: Vec<String>,
1086}
1087
1088#[derive(Debug, Default, PartialEq, Eq)]
1089pub struct SemanticUsage {
1090 pub import_binding_usage: ImportBindingUsage,
1091 pub auto_import_candidates: Vec<String>,
1092 pub(crate) vitest_vi_reference_spans: rustc_hash::FxHashSet<Span>,
1093}
1094
1095pub fn compute_semantic_usage(
1096 program: &Program<'_>,
1097 imports: &[ImportInfo],
1098 template_used: &rustc_hash::FxHashSet<String>,
1099) -> SemanticUsage {
1100 use oxc_semantic::SemanticBuilder;
1101 use rustc_hash::FxHashSet;
1102
1103 let semantic_ret = SemanticBuilder::new().build(program);
1104 let semantic = semantic_ret.semantic;
1105 let scoping = semantic.scoping();
1106 let root_scope = scoping.root_scope_id();
1107
1108 let mut unused = Vec::new();
1109 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1110 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1111 for import in imports {
1112 if import.local_name.is_empty() {
1113 continue;
1114 }
1115 let name = oxc_str::Ident::from(import.local_name.as_str());
1116 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1117 let mut has_references = false;
1118 let mut has_type_references = false;
1119 let mut has_value_references = false;
1120
1121 for reference in scoping.get_resolved_references(symbol_id) {
1122 has_references = true;
1123 has_type_references |= reference.is_type();
1124 has_value_references |= reference.is_value();
1125 }
1126
1127 if !has_references {
1128 if !template_used.contains(&import.local_name) {
1129 unused.push(import.local_name.clone());
1130 }
1131 continue;
1132 }
1133
1134 if has_type_references {
1135 type_referenced_bindings.insert(import.local_name.clone());
1136 }
1137 if has_value_references {
1138 value_referenced_bindings.insert(import.local_name.clone());
1139 }
1140 }
1141 }
1142
1143 unused.sort_unstable();
1144
1145 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1146 type_referenced_bindings.sort_unstable();
1147
1148 let mut value_referenced_bindings: Vec<String> =
1149 value_referenced_bindings.into_iter().collect();
1150 value_referenced_bindings.sort_unstable();
1151 let vitest_vi_reference_spans =
1152 compute_vitest_vi_reference_spans(&semantic, imports, root_scope);
1153
1154 SemanticUsage {
1155 import_binding_usage: ImportBindingUsage {
1156 unused,
1157 type_referenced: type_referenced_bindings,
1158 value_referenced: value_referenced_bindings,
1159 },
1160 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1161 vitest_vi_reference_spans,
1162 }
1163}
1164
1165fn compute_vitest_vi_reference_spans(
1166 semantic: &oxc_semantic::Semantic<'_>,
1167 imports: &[ImportInfo],
1168 root_scope: oxc_semantic::ScopeId,
1169) -> rustc_hash::FxHashSet<Span> {
1170 let has_direct_vitest_import = imports.iter().any(|import| {
1171 import.source == "vitest"
1172 && import.local_name == "vi"
1173 && !import.is_type_only
1174 && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi")
1175 });
1176 if !has_direct_vitest_import {
1177 return rustc_hash::FxHashSet::default();
1178 }
1179
1180 let scoping = semantic.scoping();
1181 let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from("vi")) else {
1182 return rustc_hash::FxHashSet::default();
1183 };
1184
1185 scoping
1186 .get_resolved_references(symbol_id)
1187 .filter_map(|reference| {
1188 let AstKind::IdentifierReference(identifier) =
1189 semantic.nodes().kind(reference.node_id())
1190 else {
1191 return None;
1192 };
1193 Some(identifier.span)
1194 })
1195 .collect()
1196}
1197
1198pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
1199 use oxc_semantic::SemanticBuilder;
1200
1201 let semantic_ret = SemanticBuilder::new().build(program);
1202 let semantic = semantic_ret.semantic;
1203 compute_auto_import_candidates_from_semantic(semantic.scoping())
1204}
1205
1206fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1207 use rustc_hash::FxHashSet;
1208
1209 let mut candidates: FxHashSet<String> = FxHashSet::default();
1210 for (name, reference_ids) in scoping.root_unresolved_references() {
1211 if reference_ids
1212 .iter()
1213 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1214 {
1215 candidates.insert(name.as_str().to_string());
1216 }
1217 }
1218
1219 let mut candidates: Vec<String> = candidates.into_iter().collect();
1220 candidates.sort_unstable();
1221 candidates
1222}
1223
1224pub fn compute_import_binding_usage(
1241 program: &Program<'_>,
1242 imports: &[ImportInfo],
1243 template_used: &rustc_hash::FxHashSet<String>,
1244) -> ImportBindingUsage {
1245 compute_semantic_usage(program, imports, template_used).import_binding_usage
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use super::{
1251 advance_jsdoc_brace_stack, has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag,
1252 parse_source_to_module, scan_jsdoc_imports_in,
1253 };
1254 use fallow_types::discover::FileId;
1255 use fallow_types::extract::{ImportInfo, ImportedName};
1256 use std::path::Path;
1257
1258 #[test]
1259 fn has_public_tag_matches_bare_tag() {
1260 assert!(has_public_tag(" * @public"));
1261 }
1262
1263 #[test]
1264 fn has_public_tag_matches_api_public_variant() {
1265 assert!(has_public_tag(" * @api public"));
1266 }
1267
1268 #[test]
1269 fn has_public_tag_rejects_partial_word() {
1270 assert!(!has_public_tag(" * @publicly"));
1271 }
1272
1273 #[test]
1274 fn has_public_tag_rejects_at_apipublic() {
1275 assert!(!has_public_tag(" * @apipublic"));
1276 }
1277
1278 #[test]
1279 fn has_public_tag_rejects_missing_at() {
1280 assert!(!has_public_tag(" * public"));
1281 }
1282
1283 #[test]
1284 fn has_internal_tag_matches_bare_tag() {
1285 assert!(has_internal_tag(" * @internal"));
1286 }
1287
1288 #[test]
1289 fn has_internal_tag_rejects_partial_word() {
1290 assert!(!has_internal_tag(" * @internalizer"));
1291 }
1292
1293 #[test]
1294 fn has_internal_tag_rejects_missing_at() {
1295 assert!(!has_internal_tag(" * internal"));
1296 }
1297
1298 #[test]
1299 fn has_beta_tag_matches_bare_tag() {
1300 assert!(has_beta_tag(" * @beta"));
1301 }
1302
1303 #[test]
1304 fn has_beta_tag_rejects_partial_word() {
1305 assert!(!has_beta_tag(" * @betaware"));
1306 }
1307
1308 #[test]
1309 fn has_beta_tag_rejects_missing_at() {
1310 assert!(!has_beta_tag(" * beta"));
1311 }
1312
1313 #[test]
1314 fn alpha_tag_standalone() {
1315 assert!(has_alpha_tag("@alpha"));
1316 }
1317
1318 #[test]
1319 fn alpha_tag_with_text() {
1320 assert!(has_alpha_tag("@alpha Some description"));
1321 }
1322
1323 #[test]
1324 fn alpha_tag_not_prefix() {
1325 assert!(!has_alpha_tag("@alphabet"));
1326 }
1327
1328 #[test]
1329 fn has_alpha_tag_rejects_missing_at() {
1330 assert!(!has_alpha_tag(" * alpha"));
1331 }
1332
1333 fn scan(body: &str) -> Vec<ImportInfo> {
1334 let mut imports = Vec::new();
1335 scan_jsdoc_imports_in(body, &mut imports);
1336 imports
1337 }
1338
1339 #[test]
1340 fn scan_jsdoc_single_import_with_member() {
1341 let imports = scan(" * @param foo {import('./types').Foo}");
1342 assert_eq!(imports.len(), 1);
1343 assert_eq!(imports[0].source, "./types");
1344 assert_eq!(
1345 imports[0].imported_name,
1346 ImportedName::Named("Foo".to_string())
1347 );
1348 assert!(imports[0].is_type_only);
1349 assert!(imports[0].local_name.is_empty());
1350 }
1351
1352 #[test]
1353 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1354 let info = parse_source_to_module(
1355 FileId(0),
1356 Path::new("pages/index.ts"),
1357 r"
1358 useCounter();
1359 const price = formatPrice(10);
1360 const localOnly = () => null;
1361 localOnly();
1362 type Local = UseTypeOnly;
1363 ",
1364 0,
1365 false,
1366 );
1367
1368 assert!(
1369 info.auto_import_candidates
1370 .contains(&"formatPrice".to_string())
1371 );
1372 assert!(
1373 info.auto_import_candidates
1374 .contains(&"useCounter".to_string())
1375 );
1376 assert!(
1377 !info
1378 .auto_import_candidates
1379 .contains(&"UseTypeOnly".to_string())
1380 );
1381 assert!(
1382 !info
1383 .auto_import_candidates
1384 .contains(&"localOnly".to_string())
1385 );
1386 }
1387
1388 #[test]
1389 fn script_auto_import_candidates_skip_explicit_imports() {
1390 let info = parse_source_to_module(
1391 FileId(0),
1392 Path::new("pages/index.ts"),
1393 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1394 0,
1395 false,
1396 );
1397
1398 assert!(
1399 !info
1400 .auto_import_candidates
1401 .contains(&"useCounter".to_string())
1402 );
1403 assert!(
1404 info.auto_import_candidates
1405 .contains(&"useOther".to_string())
1406 );
1407 }
1408
1409 #[test]
1410 fn scan_jsdoc_double_quoted_path() {
1411 let imports = scan(r#" * @type {import("./types").Foo}"#);
1412 assert_eq!(imports.len(), 1);
1413 assert_eq!(imports[0].source, "./types");
1414 }
1415
1416 #[test]
1417 fn scan_jsdoc_multiple_imports_in_same_body() {
1418 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1419 assert_eq!(imports.len(), 2);
1420 assert_eq!(imports[0].source, "./a");
1421 assert_eq!(imports[1].source, "./b");
1422 }
1423
1424 #[test]
1425 fn scan_jsdoc_union_annotation_captures_both_members() {
1426 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1427 assert_eq!(imports.len(), 2);
1428 assert_eq!(
1429 imports[0].imported_name,
1430 ImportedName::Named("A".to_string())
1431 );
1432 assert_eq!(
1433 imports[1].imported_name,
1434 ImportedName::Named("B".to_string())
1435 );
1436 }
1437
1438 #[test]
1439 fn scan_jsdoc_nested_member_uses_first_segment() {
1440 let imports = scan(" * @type {import('./types').ns.Foo}");
1441 assert_eq!(imports.len(), 1);
1442 assert_eq!(
1443 imports[0].imported_name,
1444 ImportedName::Named("ns".to_string())
1445 );
1446 }
1447
1448 #[test]
1449 fn scan_jsdoc_parent_relative_path() {
1450 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1451 assert_eq!(imports.len(), 1);
1452 assert_eq!(imports[0].source, "../lib/types.js");
1453 }
1454
1455 #[test]
1456 fn scan_jsdoc_bare_package_specifier() {
1457 let imports = scan(" * @type {import('@scope/pkg').Client}");
1458 assert_eq!(imports.len(), 1);
1459 assert_eq!(imports[0].source, "@scope/pkg");
1460 assert_eq!(
1461 imports[0].imported_name,
1462 ImportedName::Named("Client".to_string())
1463 );
1464 }
1465
1466 #[test]
1467 fn scan_jsdoc_without_member_is_side_effect() {
1468 let imports = scan(" * @type {import('./types')}");
1469 assert_eq!(imports.len(), 1);
1470 assert_eq!(imports[0].source, "./types");
1471 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1472 assert!(imports[0].is_type_only);
1473 }
1474
1475 #[test]
1476 fn scan_jsdoc_empty_path_is_skipped() {
1477 let imports = scan(" * @type {import('').Foo}");
1478 assert!(imports.is_empty());
1479 }
1480
1481 #[test]
1482 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1483 let imports = scan(" * @type {import('./truncated");
1484 assert!(imports.is_empty());
1485 }
1486
1487 #[test]
1488 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1489 let imports = scan(" * @type {import('./types'.Foo}");
1490 assert!(imports.is_empty());
1491 }
1492
1493 #[test]
1494 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1495 let imports = scan(" * @type {import('./types') .Foo}");
1496 assert_eq!(imports.len(), 1);
1497 assert_eq!(imports[0].source, "./types");
1498 assert_eq!(
1499 imports[0].imported_name,
1500 ImportedName::Named("Foo".to_string())
1501 );
1502 }
1503
1504 #[test]
1505 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1506 let imports = scan(" * @type {import( './types').Foo}");
1507 assert_eq!(imports.len(), 1);
1508 assert_eq!(imports[0].source, "./types");
1509 }
1510
1511 #[test]
1512 fn scan_jsdoc_non_quote_after_paren_skipped() {
1513 let imports = scan(" * @type {import(foo).Bar}");
1514 assert!(imports.is_empty());
1515 }
1516
1517 #[test]
1518 fn scan_jsdoc_ignores_prose_with_import_word() {
1519 let imports = scan(" * This is an important note about imports.");
1520 assert!(imports.is_empty());
1521 }
1522
1523 #[test]
1524 fn scan_jsdoc_utf8_path_works() {
1525 let imports = scan(" * @type {import('./héllo').Foo}");
1526 assert_eq!(imports.len(), 1);
1527 assert_eq!(imports[0].source, "./héllo");
1528 }
1529
1530 #[test]
1531 fn scan_jsdoc_empty_body_is_empty() {
1532 assert!(scan("").is_empty());
1533 }
1534
1535 #[test]
1536 fn scan_jsdoc_no_import_in_body_is_empty() {
1537 assert!(scan(" * @param foo The foo parameter").is_empty());
1538 }
1539
1540 #[test]
1546 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1547 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1550 let imports = scan(body);
1551 assert!(
1552 imports.is_empty(),
1553 "prose import() should not be matched; got: {:?}",
1554 imports
1555 .iter()
1556 .map(|i| i.source.as_str())
1557 .collect::<Vec<_>>()
1558 );
1559 }
1560
1561 #[test]
1562 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1563 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1564 let imports = scan(body);
1565 assert!(
1566 imports.is_empty(),
1567 "object-literal example import() should not be matched; got: {:?}",
1568 imports
1569 .iter()
1570 .map(|i| i.source.as_str())
1571 .collect::<Vec<_>>()
1572 );
1573 }
1574
1575 #[test]
1576 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1577 let imports = scan(" * Use {import('./prose')} as an example string.");
1578 assert!(imports.is_empty());
1579 }
1580
1581 #[test]
1582 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1583 let imports = scan("\n * @example\n * { import('./prose') }\n");
1584 assert!(imports.is_empty());
1585 }
1586
1587 #[test]
1591 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1592 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1593 let imports = scan(body);
1594 assert_eq!(imports.len(), 1, "got: {imports:?}");
1595 assert_eq!(imports[0].source, "./real");
1596 assert_eq!(
1597 imports[0].imported_name,
1598 ImportedName::Named("Foo".to_string())
1599 );
1600 }
1601
1602 #[test]
1603 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1604 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1605 let imports = scan(body);
1606 assert_eq!(imports.len(), 1, "got: {imports:?}");
1607 assert_eq!(imports[0].source, "./real");
1608 assert_eq!(
1609 imports[0].imported_name,
1610 ImportedName::Named("Foo".to_string())
1611 );
1612 }
1613
1614 #[test]
1615 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1616 let body = "\n * @type\n * { import('./real').Foo }\n";
1617 let imports = scan(body);
1618 assert_eq!(imports.len(), 1, "got: {imports:?}");
1619 assert_eq!(imports[0].source, "./real");
1620 assert_eq!(
1621 imports[0].imported_name,
1622 ImportedName::Named("Foo".to_string())
1623 );
1624 }
1625
1626 #[test]
1627 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1628 let imports = scan(" * @satisfies {import('./real').Foo}");
1629 assert_eq!(imports.len(), 1, "got: {imports:?}");
1630 assert_eq!(imports[0].source, "./real");
1631 assert_eq!(
1632 imports[0].imported_name,
1633 ImportedName::Named("Foo".to_string())
1634 );
1635 }
1636
1637 #[test]
1638 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1639 let imports = scan(" * @template {import('./real').Foo} T");
1640 assert_eq!(imports.len(), 1, "got: {imports:?}");
1641 assert_eq!(imports[0].source, "./real");
1642 assert_eq!(
1643 imports[0].imported_name,
1644 ImportedName::Named("Foo".to_string())
1645 );
1646 }
1647
1648 #[test]
1649 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1650 let imports = scan(" * @enum {import('./real').Foo}");
1651 assert_eq!(imports.len(), 1, "got: {imports:?}");
1652 assert_eq!(imports[0].source, "./real");
1653 assert_eq!(
1654 imports[0].imported_name,
1655 ImportedName::Named("Foo".to_string())
1656 );
1657 }
1658
1659 #[test]
1660 fn scan_jsdoc_appends_to_existing_imports() {
1661 let mut imports = vec![ImportInfo {
1662 source: "existing".to_string(),
1663 imported_name: ImportedName::Default,
1664 local_name: "existing".to_string(),
1665 is_type_only: false,
1666 from_style: false,
1667 span: oxc_span::Span::default(),
1668 source_span: oxc_span::Span::default(),
1669 }];
1670 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1671 assert_eq!(imports.len(), 2);
1672 assert_eq!(imports[0].source, "existing");
1673 assert_eq!(imports[1].source, "./new");
1674 }
1675
1676 #[test]
1677 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1678 let imports = scan(" * @type {import('./t').Abc}");
1679 assert_eq!(imports.len(), 1);
1680 assert_eq!(
1681 imports[0].imported_name,
1682 ImportedName::Named("Abc".to_string())
1683 );
1684 }
1685
1686 #[test]
1687 fn scan_jsdoc_empty_member_name_is_skipped() {
1688 let imports = scan(" * @type {import('./x').}");
1689 assert!(imports.is_empty());
1690 }
1691
1692 #[test]
1693 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
1694 use std::fmt::Write as _;
1700 let mut body = String::from("/**\n");
1701 for i in 0..200 {
1702 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
1703 }
1704 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
1707 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
1708 body.push_str(" */\n");
1709
1710 let imports = scan(&body);
1711 assert_eq!(imports.len(), 201, "got: {imports:?}");
1712 for (i, import) in imports.iter().take(200).enumerate() {
1713 assert_eq!(import.source, format!("./m{i}"));
1714 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
1715 assert!(import.is_type_only);
1716 assert!(import.local_name.is_empty());
1717 }
1718 assert_eq!(imports[200].source, "./deep");
1720 assert_eq!(
1721 imports[200].imported_name,
1722 ImportedName::Named("D".to_string())
1723 );
1724 }
1725
1726 #[test]
1727 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
1728 let cases = [
1732 " * @type {import('./a').A} and {plain} then {import('./b').B}",
1733 " * @remarks { import('./skip') } @param x {import('./c').C}",
1734 " * text } stray close { import('./d').D } trailing",
1735 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
1736 ];
1737 for body in cases {
1738 let bytes = body.as_bytes();
1739 let mut cursor = 0;
1740 while let Some(rel) = body[cursor..].find("import(") {
1741 let import_pos = cursor + rel;
1742 let mut fresh = Vec::new();
1744 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
1745 match b {
1746 b'{' => fresh.push(idx),
1747 b'}' => {
1748 fresh.pop();
1749 }
1750 _ => {}
1751 }
1752 }
1753 let mut stack = Vec::new();
1754 let mut scanned = 0;
1755 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
1756 assert_eq!(
1757 stack.last().copied(),
1758 fresh.last().copied(),
1759 "enclosing brace mismatch at {import_pos} in {body:?}"
1760 );
1761 cursor = import_pos + "import(".len();
1762 }
1763 }
1764 }
1765}