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 degradation: ParseDegradation,
32}
33
34fn source_type_for_path(path: &Path) -> SourceType {
35 match path.extension().and_then(|ext| ext.to_str()) {
36 Some("gts") => SourceType::ts(),
37 Some("gjs") => SourceType::mjs(),
38 _ => SourceType::from_path(path).unwrap_or_default(),
39 }
40}
41
42pub fn parse_source_to_module(
49 file_id: FileId,
50 path: &Path,
51 source: &str,
52 content_hash: u64,
53 need_complexity: bool,
54) -> ModuleInfo {
55 let mut module =
56 parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
57 module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
58 module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
59 let federation_facts =
60 crate::federation_runtime::extract_federation_runtime_facts(path, source);
61 if !federation_facts.is_empty() {
62 module.semantic_facts = module
63 .semantic_facts
64 .iter()
65 .cloned()
66 .chain(federation_facts)
67 .collect();
68 }
69 if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
73 module.load_return_keys = Vec::new();
74 module.has_unharvestable_load = false;
75 }
76 module
77}
78
79fn is_sveltekit_page_load_file(path: &Path) -> bool {
84 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
85 return false;
86 };
87 matches!(
88 name,
89 "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
90 )
91}
92
93fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
94 if is_sveltekit_page_load_file(path) {
95 return RouteLoadHarvestMode::SvelteKitPage;
96 }
97 if is_conventional_route_loader_file(path) {
98 return RouteLoadHarvestMode::ConventionalRoute;
99 }
100 RouteLoadHarvestMode::None
101}
102
103fn is_conventional_route_loader_file(path: &Path) -> bool {
104 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
105 return false;
106 };
107 if name.starts_with('+') {
108 return false;
109 }
110 if !matches!(
111 path.extension().and_then(|ext| ext.to_str()),
112 Some("ts" | "tsx" | "js" | "jsx")
113 ) {
114 return false;
115 }
116 if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
117 && path
118 .parent()
119 .and_then(|parent| parent.file_name())
120 .and_then(|part| part.to_str())
121 .is_some_and(|part| matches!(part, "app" | "src"))
122 {
123 return true;
124 }
125 path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
126}
127
128fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
129 let mut previous = None;
130 for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
131 if previous == Some(app_dir) && part == "routes" {
132 return true;
133 }
134 previous = Some(part);
135 }
136 false
137}
138
139fn parse_source_to_module_inner(
140 file_id: FileId,
141 path: &Path,
142 source: &str,
143 content_hash: u64,
144 need_complexity: bool,
145) -> ModuleInfo {
146 let source = crate::strip_bom(source);
147 if let Some(module) =
148 parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
149 {
150 return module;
151 }
152
153 let stripped_glimmer_source = is_glimmer_file(path)
154 .then(|| strip_glimmer_templates(source))
155 .flatten();
156 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
157 let source_type = source_type_for_path(path);
158 let allocator = Allocator::default();
159 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
160 let mut degradation = ParseDegradation::from_parser(&parser_return);
161
162 let mut parsed_suppressions =
163 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
164
165 let (mut extractor, mut semantic_usage) =
166 build_primary_extractor(&parser_return.program, path, source, source_type);
167
168 let line_offsets = fallow_types::extract::compute_line_offsets(source);
169
170 let (mut complexity, mut flag_uses) = compute_primary_complexity_and_flags(
171 &parser_return.program,
172 parser_source,
173 &extractor.inline_template_findings,
174 &line_offsets,
175 need_complexity,
176 );
177
178 apply_jsx_retry_or_jsdoc(
179 &JsxRetryOrJsdocInput {
180 path,
181 parser_source,
182 source_type,
183 need_complexity,
184 line_offsets: &line_offsets,
185 comments: &parser_return.program.comments,
186 source,
187 export_statements: &crate::jsdoc_attach::export_statement_spans(&parser_return.program),
188 },
189 &mut ParseOutputs {
190 extractor: &mut extractor,
191 semantic_usage: &mut semantic_usage,
192 complexity: &mut complexity,
193 flag_uses: &mut flag_uses,
194 parsed_suppressions: &mut parsed_suppressions,
195 degradation: &mut degradation,
196 },
197 );
198
199 assemble_module_info(ModuleAssemblyInput {
200 extractor,
201 file_id,
202 content_hash,
203 parsed_suppressions,
204 semantic_usage,
205 line_offsets,
206 complexity,
207 flag_uses,
208 degradation,
209 })
210}
211
212struct JsxRetryOrJsdocInput<'a> {
214 path: &'a Path,
215 parser_source: &'a str,
216 source_type: SourceType,
217 need_complexity: bool,
218 line_offsets: &'a [u32],
219 comments: &'a [Comment],
220 source: &'a str,
221 export_statements: &'a [oxc_span::Span],
222}
223
224struct ModuleAssemblyInput {
225 extractor: ModuleInfoExtractor,
226 file_id: FileId,
227 content_hash: u64,
228 parsed_suppressions: crate::suppress::ParsedSuppressions,
229 semantic_usage: SemanticUsage,
230 line_offsets: Vec<u32>,
231 complexity: Vec<FunctionComplexity>,
232 flag_uses: Vec<FlagUse>,
233 degradation: ParseDegradation,
234}
235
236#[derive(Debug, Clone, Copy, Default)]
245struct ParseDegradation {
246 error_count: u32,
247 panicked: bool,
248}
249
250impl ParseDegradation {
251 fn from_parser(parser_return: &oxc_parser::ParserReturn<'_>) -> Self {
252 Self {
253 error_count: u32::try_from(parser_return.errors.len()).unwrap_or(u32::MAX),
254 panicked: parser_return.panicked,
255 }
256 }
257}
258
259fn build_primary_extractor(
262 program: &Program<'_>,
263 path: &Path,
264 source: &str,
265 source_type: SourceType,
266) -> (ModuleInfoExtractor, SemanticUsage) {
267 let mut extractor = ModuleInfoExtractor::new();
268 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
269 extractor.jsx_capable = source_type.is_jsx();
273 extractor.visit_program(program);
274 extractor.resolve_pending_local_export_specifiers();
275
276 let template_used_imports =
277 collect_glimmer_template_into_extractor(&mut extractor, path, source);
278 let semantic_usage =
279 compute_semantic_usage_for_extractor(program, &mut extractor, &template_used_imports);
280 extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
281 (extractor, semantic_usage)
282}
283
284fn compute_primary_complexity_and_flags(
287 program: &Program<'_>,
288 parser_source: &str,
289 inline_template_findings: &[crate::visitor::InlineTemplateFinding],
290 line_offsets: &[u32],
291 need_complexity: bool,
292) -> (Vec<FunctionComplexity>, Vec<FlagUse>) {
293 let mut complexity = if need_complexity {
294 crate::complexity::compute_complexity(program, parser_source, line_offsets)
295 } else {
296 Vec::new()
297 };
298 if need_complexity {
299 append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
300 }
301
302 let flag_uses = crate::flags::extract_flags(
303 program,
304 line_offsets,
305 &[], &[], false, );
309 (complexity, flag_uses)
310}
311
312struct ParseOutputs<'a> {
314 extractor: &'a mut ModuleInfoExtractor,
315 semantic_usage: &'a mut SemanticUsage,
316 complexity: &'a mut Vec<FunctionComplexity>,
317 flag_uses: &'a mut Vec<FlagUse>,
318 parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
319 degradation: &'a mut ParseDegradation,
320}
321
322fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
326 let retry_input = JsxRetryInput {
327 path: input.path,
328 source: input.source,
329 parser_source: input.parser_source,
330 source_type: input.source_type,
331 total_extracted: outputs.extractor.exports.len()
332 + outputs.extractor.imports.len()
333 + outputs.extractor.re_exports.len(),
334 need_complexity: input.need_complexity,
335 line_offsets: input.line_offsets,
336 };
337 let Some(retry) = parse_with_jsx_retry(&retry_input) else {
338 apply_jsdoc_tags_to_extractor(
339 &mut *outputs.extractor,
340 input.comments,
341 input.source,
342 input.export_statements,
343 );
344 return;
345 };
346 *outputs.extractor = retry.extractor;
347 *outputs.semantic_usage = retry.semantic_usage;
348 *outputs.complexity = retry.complexity;
349 *outputs.flag_uses = retry.flag_uses;
350 *outputs.parsed_suppressions = retry.parsed_suppressions;
351 *outputs.degradation = retry.degradation;
354}
355
356fn apply_jsdoc_tags_to_extractor(
359 extractor: &mut ModuleInfoExtractor,
360 comments: &[Comment],
361 source: &str,
362 statements: &[oxc_span::Span],
363) {
364 apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source, statements);
365 crate::jsdoc_deprecated::apply_jsdoc_deprecated_tags(
366 &mut extractor.exports,
367 comments,
368 source,
369 statements,
370 );
371 extract_jsdoc_import_types(&mut extractor.imports, comments, source);
372}
373
374fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
377 let ModuleAssemblyInput {
378 extractor,
379 file_id,
380 content_hash,
381 parsed_suppressions,
382 semantic_usage,
383 line_offsets,
384 complexity,
385 flag_uses,
386 degradation,
387 } = input;
388 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
389 info.parse_error_count = degradation.error_count;
390 info.parse_panicked = degradation.panicked;
391 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
392 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
393 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
394 info.auto_import_candidates
395 .extend(semantic_usage.auto_import_candidates);
396 info.auto_import_candidates.sort_unstable();
397 info.auto_import_candidates.dedup();
398 append_declaration_merge_facts(
399 &mut info.semantic_facts,
400 semantic_usage.declaration_merges,
401 0,
402 );
403 info.line_offsets = line_offsets;
404 info.complexity = complexity;
405 info.flag_uses = flag_uses;
406 info
407}
408
409pub fn append_declaration_merge_facts(
410 facts: &mut std::sync::Arc<[fallow_types::extract::SemanticFact]>,
411 mut groups: Vec<fallow_types::extract::DeclarationMergeFact>,
412 byte_offset: u32,
413) {
414 if groups.is_empty() {
415 return;
416 }
417 if byte_offset != 0 {
418 for group in &mut groups {
419 for (start, end) in &mut group.export_spans {
420 *start += byte_offset;
421 *end += byte_offset;
422 }
423 }
424 }
425 let mut merged = std::mem::take(facts).to_vec();
426 merged.extend(
427 groups
428 .into_iter()
429 .map(fallow_types::extract::SemanticFact::DeclarationMerge),
430 );
431 *facts = merged.into();
432}
433
434struct JsxRetryInput<'a> {
435 path: &'a Path,
436 source: &'a str,
437 parser_source: &'a str,
438 source_type: SourceType,
439 total_extracted: usize,
440 need_complexity: bool,
441 line_offsets: &'a [u32],
442}
443
444fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
445 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
446 return None;
447 }
448
449 let jsx_type = if input.source_type.is_typescript() {
450 SourceType::tsx()
451 } else {
452 SourceType::jsx()
453 };
454 let allocator = Allocator::default();
455 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
456 let degradation = ParseDegradation::from_parser(&retry_return);
457 let mut extractor = ModuleInfoExtractor::new();
458 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
459 extractor.jsx_capable = true;
462 extractor.visit_program(&retry_return.program);
463 extractor.resolve_pending_local_export_specifiers();
464 let retry_total =
465 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
466 if retry_total <= input.total_extracted {
467 return None;
468 }
469
470 let template_used_imports =
471 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
472 let semantic_usage = compute_semantic_usage_for_extractor(
473 &retry_return.program,
474 &mut extractor,
475 &template_used_imports,
476 );
477 extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
478 let complexity = retry_complexity(
479 input.need_complexity,
480 &retry_return.program,
481 input.parser_source,
482 input.line_offsets,
483 &extractor,
484 );
485 let flag_uses =
486 crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
487 let parsed_suppressions =
488 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
489 let export_statements = crate::jsdoc_attach::export_statement_spans(&retry_return.program);
490 apply_jsdoc_visibility_tags(
491 &mut extractor.exports,
492 &retry_return.program.comments,
493 input.source,
494 &export_statements,
495 );
496 crate::jsdoc_deprecated::apply_jsdoc_deprecated_tags(
497 &mut extractor.exports,
498 &retry_return.program.comments,
499 input.source,
500 &export_statements,
501 );
502 extract_jsdoc_import_types(
503 &mut extractor.imports,
504 &retry_return.program.comments,
505 input.source,
506 );
507 Some(JsxRetryParse {
508 extractor,
509 semantic_usage,
510 complexity,
511 flag_uses,
512 parsed_suppressions,
513 degradation,
514 })
515}
516
517fn retry_complexity(
518 need_complexity: bool,
519 program: &Program<'_>,
520 parser_source: &str,
521 line_offsets: &[u32],
522 extractor: &ModuleInfoExtractor,
523) -> Vec<FunctionComplexity> {
524 if !need_complexity {
525 return Vec::new();
526 }
527 let mut complexity =
528 crate::complexity::compute_complexity(program, parser_source, line_offsets);
529 append_inline_template_complexity(
530 &mut complexity,
531 &extractor.inline_template_findings,
532 line_offsets,
533 );
534 complexity
535}
536
537fn parse_non_js_source_to_module(
538 file_id: FileId,
539 path: &Path,
540 source: &str,
541 content_hash: u64,
542 need_complexity: bool,
543) -> Option<ModuleInfo> {
544 if is_sfc_file(path) {
545 return Some(parse_sfc_to_module(
546 file_id,
547 path,
548 source,
549 content_hash,
550 need_complexity,
551 ));
552 }
553 if is_astro_file(path) {
554 return Some(parse_astro_to_module(
555 file_id,
556 source,
557 content_hash,
558 need_complexity,
559 ));
560 }
561 if is_mdx_file(path) {
562 return Some(parse_mdx_to_module(file_id, source, content_hash));
563 }
564 if is_css_file(path) {
565 return Some(parse_css_to_module(file_id, path, source, content_hash));
566 }
567 if is_graphql_file(path) {
568 return Some(parse_graphql_to_module(file_id, source, content_hash));
569 }
570 if is_html_file(path) {
571 return Some(parse_html_to_module_with_complexity(
572 file_id,
573 source,
574 content_hash,
575 need_complexity,
576 ));
577 }
578 None
579}
580
581fn collect_glimmer_template_into_extractor(
603 extractor: &mut ModuleInfoExtractor,
604 path: &Path,
605 source: &str,
606) -> rustc_hash::FxHashSet<String> {
607 use rustc_hash::FxHashSet;
608
609 if !is_glimmer_file(path) {
610 return FxHashSet::default();
611 }
612 let template_ranges = crate::glimmer::find_template_ranges(source);
613 if template_ranges.is_empty() {
614 return FxHashSet::default();
615 }
616
617 let imported_bindings: FxHashSet<String> = extractor
618 .imports
619 .iter()
620 .filter(|import| !import.local_name.is_empty())
621 .map(|import| import.local_name.clone())
622 .collect();
623
624 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
625 source,
626 &template_ranges,
627 &imported_bindings,
628 );
629 extractor.member_accesses.extend(usage.member_accesses);
630 usage.used_bindings
631}
632
633fn append_inline_template_complexity(
644 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
645 findings: &[crate::visitor::InlineTemplateFinding],
646 line_offsets: &[u32],
647) {
648 for finding in findings {
649 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
650 &finding.template_source,
651 ) else {
652 continue;
653 };
654 let (line, col) =
655 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
656 fc.line = line;
657 fc.col = col;
658 complexity.push(fc);
659 }
660}
661
662fn apply_jsdoc_visibility_tags(
670 exports: &mut [ExportInfo],
671 comments: &[Comment],
672 source: &str,
673 statements: &[oxc_span::Span],
674) {
675 if exports.is_empty() || comments.is_empty() {
676 return;
677 }
678
679 let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
680 if tag_offsets.is_empty() {
681 return;
682 }
683 tag_offsets.sort_by_key(|&(offset, _, _)| offset);
685
686 for export in exports.iter_mut() {
687 apply_visibility_tag_to_export(export, &tag_offsets, statements);
688 }
689}
690
691fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
694 if has_public_tag(text) {
695 Some((VisibilityTag::Public, None))
696 } else if bare_jsdoc_tag_end(text, "@internal").is_some() {
697 Some((VisibilityTag::Internal, None))
698 } else if bare_jsdoc_tag_end(text, "@alpha").is_some() {
699 Some((VisibilityTag::Alpha, None))
700 } else if bare_jsdoc_tag_end(text, "@beta").is_some() {
701 Some((VisibilityTag::Beta, None))
702 } else {
703 bare_jsdoc_tag_end(text, "@expected-unused").map(|after| {
704 (
705 VisibilityTag::ExpectedUnused,
706 split_jsdoc_reason(&text[after..]),
707 )
708 })
709 }
710}
711
712fn collect_jsdoc_tag_offsets(
715 comments: &[Comment],
716 source: &str,
717) -> Vec<(u32, VisibilityTag, Option<String>)> {
718 let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
719 for comment in comments {
720 if !comment.is_jsdoc() {
721 continue;
722 }
723 let content_span = comment.content_span();
724 let start = content_span.start as usize;
725 let end = (content_span.end as usize).min(source.len());
726 if start >= end {
727 continue;
728 }
729 if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
730 tag_offsets.push((comment.attached_to, tag, reason));
731 }
732 }
733 tag_offsets
734}
735
736fn apply_visibility_tag_to_export(
738 export: &mut ExportInfo,
739 tag_offsets: &[(u32, VisibilityTag, Option<String>)],
740 statements: &[oxc_span::Span],
741) {
742 if export.span.start == 0 && export.span.end == 0 {
743 return;
744 }
745 let found = crate::jsdoc_attach::tag_index_for_export(
746 tag_offsets,
747 |&(offset, _, _)| offset,
748 export.span.start,
749 statements,
750 );
751 if let Some(idx) = found {
752 export.visibility = tag_offsets[idx].1;
753 export
754 .expected_unused_reason
755 .clone_from(&tag_offsets[idx].2);
756 }
757}
758
759fn split_jsdoc_reason(rest: &str) -> Option<String> {
760 for (idx, _) in rest.match_indices("--") {
761 let before_ok = idx == 0
762 || rest[..idx]
763 .chars()
764 .next_back()
765 .is_some_and(char::is_whitespace);
766 let after_idx = idx + 2;
767 let after_ok = after_idx == rest.len()
768 || rest[after_idx..]
769 .chars()
770 .next()
771 .is_some_and(char::is_whitespace);
772 if before_ok && after_ok {
773 let reason = rest[after_idx..].trim();
774 return if reason.is_empty() {
775 None
776 } else {
777 Some(reason.to_string())
778 };
779 }
780 }
781
782 None
783}
784
785const fn is_ident_char(b: u8) -> bool {
787 b.is_ascii_alphanumeric() || b == b'_'
788}
789
790fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
814 if comments.is_empty() {
815 return;
816 }
817
818 for comment in comments {
819 if !comment.is_jsdoc() {
820 continue;
821 }
822 let content_span = comment.content_span();
823 let start = content_span.start as usize;
824 let end = (content_span.end as usize).min(source.len());
825 if start >= end {
826 continue;
827 }
828 scan_jsdoc_imports_in(&source[start..end], imports);
829 }
830}
831
832fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
840 let bytes = body.as_bytes();
841 let mut cursor = 0;
842 let mut brace_stack: Vec<usize> = Vec::new();
849 let mut scanned = 0;
850 while let Some(rel) = body[cursor..].find("import(") {
851 let import_pos = cursor + rel;
852 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
853 if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
854 cursor = import_pos + "import(".len();
855 continue;
856 }
857 let open = import_pos + "import(".len();
858 match locate_jsdoc_import_path(body, bytes, open) {
859 JsdocImportScan::Stop => break,
860 JsdocImportScan::Skip(next) => {
861 cursor = next;
862 }
863 JsdocImportScan::Found { path, after_paren } => {
864 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
865 }
866 }
867 }
868}
869
870enum JsdocImportScan<'a> {
873 Stop,
875 Skip(usize),
877 Found { path: &'a str, after_paren: usize },
879}
880
881fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
884 if open >= bytes.len() {
885 return JsdocImportScan::Stop;
886 }
887 let mut i = open;
888 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
889 i += 1;
890 }
891 if i >= bytes.len() {
892 return JsdocImportScan::Stop;
893 }
894 let quote = bytes[i];
895 if quote != b'\'' && quote != b'"' {
896 return JsdocImportScan::Skip(open);
897 }
898 let path_start = i + 1;
899 let Some(rel_close) = body[path_start..].find(quote as char) else {
900 return JsdocImportScan::Stop;
901 };
902 let path_end = path_start + rel_close;
903 let path = &body[path_start..path_end];
904 if path.is_empty() {
905 return JsdocImportScan::Skip(path_end + 1);
906 }
907 let mut j = path_end + 1;
908 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
909 j += 1;
910 }
911 if j >= bytes.len() || bytes[j] != b')' {
912 return JsdocImportScan::Skip(path_end + 1);
913 }
914 j += 1;
915 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
916 j += 1;
917 }
918 JsdocImportScan::Found {
919 path,
920 after_paren: j,
921 }
922}
923
924fn resolve_jsdoc_import(
927 body: &str,
928 bytes: &[u8],
929 after_paren: usize,
930 path: &str,
931 imports: &mut Vec<ImportInfo>,
932) -> usize {
933 let mut j = after_paren;
934 if j >= bytes.len() || bytes[j] != b'.' {
935 imports.push(jsdoc_type_import(
936 path,
937 fallow_types::extract::ImportedName::SideEffect,
938 ));
939 return after_paren;
940 }
941 j += 1;
942 let name_start = j;
943 while j < bytes.len() && is_ident_char(bytes[j]) {
944 j += 1;
945 }
946 if name_start == j {
947 return after_paren;
950 }
951 let member = &body[name_start..j];
952 imports.push(jsdoc_type_import(
953 path,
954 fallow_types::extract::ImportedName::Named(member.to_string()),
955 ));
956 j
957}
958
959fn jsdoc_type_import(
962 source: &str,
963 imported_name: fallow_types::extract::ImportedName,
964) -> ImportInfo {
965 ImportInfo {
966 source: source.to_string(),
967 imported_name,
968 local_name: String::new(),
969 is_type_only: true,
970 is_type_only_star: false,
971 from_style: false,
972 span: oxc_span::Span::default(),
973 source_span: oxc_span::Span::default(),
974 }
975}
976
977fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
983 let Some(open_brace) = open_brace else {
984 return false;
985 };
986
987 let prefix = line_prefix_before(body, open_brace);
988 if jsdoc_line_prefix_has_type_tag(prefix) {
989 return true;
990 }
991
992 strip_jsdoc_line_prefix(prefix).is_empty()
993 && preceding_jsdoc_line_has_type_tag(body, open_brace)
994 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
995}
996
997fn advance_jsdoc_brace_stack(
1007 body: &[u8],
1008 stack: &mut Vec<usize>,
1009 scanned: &mut usize,
1010 up_to: usize,
1011) {
1012 let up_to = up_to.min(body.len());
1013 while *scanned < up_to {
1014 match body[*scanned] {
1015 b'{' => stack.push(*scanned),
1016 b'}' => {
1017 stack.pop();
1018 }
1019 _ => {}
1020 }
1021 *scanned += 1;
1022 }
1023}
1024
1025fn line_prefix_before(body: &[u8], pos: usize) -> &str {
1026 let start = body[..pos]
1027 .iter()
1028 .rposition(|&b| b == b'\n')
1029 .map_or(0, |idx| idx + 1);
1030 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
1031}
1032
1033fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
1034 let trimmed = prefix.trim_start();
1035 trimmed
1036 .strip_prefix('*')
1037 .map_or(trimmed, |rest| rest.trim_start())
1038}
1039
1040fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
1041 const TYPE_TAGS: [&str; 17] = [
1042 "@arg",
1043 "@argument",
1044 "@augments",
1045 "@callback",
1046 "@enum",
1047 "@extends",
1048 "@implements",
1049 "@param",
1050 "@property",
1051 "@prop",
1052 "@return",
1053 "@returns",
1054 "@satisfies",
1055 "@template",
1056 "@this",
1057 "@type",
1058 "@typedef",
1059 ];
1060
1061 let prefix = strip_jsdoc_line_prefix(prefix);
1062 TYPE_TAGS
1063 .iter()
1064 .any(|tag| bare_jsdoc_tag_end(prefix, tag).is_some())
1065}
1066
1067fn bare_jsdoc_tag_end(text: &str, tag: &str) -> Option<usize> {
1070 text.match_indices(tag)
1071 .map(|(idx, _)| idx + tag.len())
1072 .find(|&after| after >= text.len() || !is_ident_char(text.as_bytes()[after]))
1073}
1074
1075fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1076 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1077 return false;
1078 };
1079
1080 let line_start = body[..line_end]
1081 .iter()
1082 .rposition(|&b| b == b'\n')
1083 .map_or(0, |idx| idx + 1);
1084
1085 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1086}
1087
1088fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1089 let mut at_line_start = true;
1090 let mut i = start.min(body.len());
1091 let end = end.min(body.len());
1092 while i < end {
1093 match body[i] {
1094 b'\n' => {
1095 at_line_start = true;
1096 i += 1;
1097 }
1098 b'\r' | b'\t' | b' ' => {
1099 i += 1;
1100 }
1101 b'*' if at_line_start => {
1102 at_line_start = false;
1103 i += 1;
1104 }
1105 _ => return false,
1106 }
1107 }
1108 true
1109}
1110
1111fn has_public_tag(comment_text: &str) -> bool {
1113 if bare_jsdoc_tag_end(comment_text, "@public").is_some() {
1114 return true;
1115 }
1116 for (i, _) in comment_text.match_indices("@api") {
1117 let after = i + "@api".len();
1118 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1119 let rest = comment_text[after..].trim_start();
1120 if rest.starts_with("public") {
1121 let after_public = "public".len();
1122 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1123 return true;
1124 }
1125 }
1126 }
1127 }
1128 false
1129}
1130
1131#[derive(Debug, Default, PartialEq, Eq)]
1132pub struct ImportBindingUsage {
1133 pub unused: Vec<String>,
1134 pub type_referenced: Vec<String>,
1135 pub value_referenced: Vec<String>,
1136}
1137
1138#[derive(Debug, Default, PartialEq, Eq)]
1147pub struct MockApiReferenceSpans {
1148 pub(crate) mock_bindings: rustc_hash::FxHashSet<Span>,
1149 pub(crate) vitest_namespaces: rustc_hash::FxHashSet<Span>,
1150}
1151
1152#[derive(Debug, Default, PartialEq, Eq)]
1153pub struct SemanticUsage {
1154 pub import_binding_usage: ImportBindingUsage,
1155 pub auto_import_candidates: Vec<String>,
1156 pub declaration_merges: Vec<fallow_types::extract::DeclarationMergeFact>,
1157 pub(crate) mock_api_reference_spans: MockApiReferenceSpans,
1158 pub(crate) module_binding_reference_spans: rustc_hash::FxHashSet<Span>,
1159 pub(crate) unreferenced_import_equals_bindings: Vec<String>,
1164}
1165
1166pub fn compute_semantic_usage_for_extractor(
1167 program: &Program<'_>,
1168 extractor: &mut ModuleInfoExtractor,
1169 template_used: &rustc_hash::FxHashSet<String>,
1170) -> SemanticUsage {
1171 let computed_enum_key_spans = extractor.computed_enum_key_reference_spans();
1172 let require_namespace_bindings = extractor.require_namespace_bindings();
1173 let mut semantic_usage = compute_semantic_usage_with_candidates(
1174 program,
1175 &extractor.imports,
1176 &require_namespace_bindings,
1177 template_used,
1178 &computed_enum_key_spans,
1179 );
1180 extractor.resolve_computed_enum_key_uses(&semantic_usage.module_binding_reference_spans);
1181 report_unreferenced_import_equals_bindings(
1182 &mut semantic_usage,
1183 &extractor.exported_import_equals_names,
1184 );
1185 semantic_usage
1186}
1187
1188fn report_unreferenced_import_equals_bindings(
1203 semantic_usage: &mut SemanticUsage,
1204 exported_import_equals_names: &[String],
1205) {
1206 let unreferenced = std::mem::take(&mut semantic_usage.unreferenced_import_equals_bindings);
1207 if unreferenced.is_empty() {
1208 return;
1209 }
1210 let unused = &mut semantic_usage.import_binding_usage.unused;
1211 unused.extend(unreferenced.into_iter().filter(|name| {
1212 !exported_import_equals_names
1213 .iter()
1214 .any(|exported| exported == name)
1215 }));
1216 unused.sort_unstable();
1220 unused.dedup();
1221}
1222
1223fn compute_semantic_usage_with_candidates(
1224 program: &Program<'_>,
1225 imports: &[ImportInfo],
1226 require_namespace_bindings: &[String],
1227 template_used: &rustc_hash::FxHashSet<String>,
1228 module_binding_candidates: &rustc_hash::FxHashSet<Span>,
1229) -> SemanticUsage {
1230 use oxc_semantic::SemanticBuilder;
1231 use rustc_hash::FxHashSet;
1232
1233 let semantic_ret = SemanticBuilder::new().build(program);
1234 let semantic = semantic_ret.semantic;
1235 let scoping = semantic.scoping();
1236 let root_scope = scoping.root_scope_id();
1237
1238 let mut unused = Vec::new();
1239 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1240 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1241 for import in imports {
1242 if import.local_name.is_empty() {
1243 continue;
1244 }
1245 if let Some((has_references, has_type_references, has_value_references)) =
1246 binding_reference_usage(scoping, &import.local_name)
1247 {
1248 if !has_references {
1249 if !template_used.contains(&import.local_name) {
1250 unused.push(import.local_name.clone());
1251 }
1252 continue;
1253 }
1254
1255 if has_type_references {
1256 type_referenced_bindings.insert(import.local_name.clone());
1257 }
1258 if has_value_references {
1259 value_referenced_bindings.insert(import.local_name.clone());
1260 }
1261 }
1262 }
1263
1264 let import_equals = classify_import_equals_bindings(
1265 scoping,
1266 require_namespace_bindings,
1267 template_used,
1268 &mut type_referenced_bindings,
1269 &mut value_referenced_bindings,
1270 );
1271
1272 unused.sort_unstable();
1273
1274 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1275 type_referenced_bindings.sort_unstable();
1276
1277 let mut value_referenced_bindings: Vec<String> =
1278 value_referenced_bindings.into_iter().collect();
1279 value_referenced_bindings.sort_unstable();
1280 let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1281 let declaration_merges = declaration_merge_facts(&semantic);
1282 let mut module_binding_reference_spans = FxHashSet::default();
1283 if !module_binding_candidates.is_empty() {
1284 for symbol_id in scoping.symbol_ids() {
1285 if scoping.symbol_scope_id(symbol_id) != root_scope {
1286 continue;
1287 }
1288 module_binding_reference_spans.extend(
1289 scoping
1290 .get_resolved_references(symbol_id)
1291 .filter_map(|reference| {
1292 let AstKind::IdentifierReference(identifier) =
1293 semantic.nodes().kind(reference.node_id())
1294 else {
1295 return None;
1296 };
1297 module_binding_candidates
1298 .contains(&identifier.span)
1299 .then_some(identifier.span)
1300 }),
1301 );
1302 }
1303 }
1304
1305 SemanticUsage {
1306 import_binding_usage: ImportBindingUsage {
1307 unused,
1308 type_referenced: type_referenced_bindings,
1309 value_referenced: value_referenced_bindings,
1310 },
1311 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1312 declaration_merges,
1313 mock_api_reference_spans,
1314 module_binding_reference_spans,
1315 unreferenced_import_equals_bindings: import_equals.unreferenced,
1316 }
1317}
1318
1319#[derive(Default)]
1321struct ImportEqualsClassification {
1322 unreferenced: Vec<String>,
1324}
1325
1326fn binding_reference_usage(
1331 scoping: &oxc_semantic::Scoping,
1332 local_name: &str,
1333) -> Option<(bool, bool, bool)> {
1334 let mut found_binding = false;
1335 let mut has_references = false;
1336 let mut has_type_references = false;
1337 let mut has_value_references = false;
1338 for symbol_id in scoping
1339 .symbol_ids()
1340 .filter(|symbol_id| scoping.symbol_name(*symbol_id) == local_name)
1341 {
1342 found_binding = true;
1343 for reference in scoping.get_resolved_references(symbol_id) {
1344 has_references = true;
1345 has_type_references |= reference.is_type();
1346 has_value_references |= reference.is_value();
1347 }
1348 }
1349 if found_binding
1350 && let Some(reference_ids) = scoping.root_unresolved_references().get(local_name)
1351 {
1352 for reference_id in reference_ids {
1353 let reference = scoping.get_reference(*reference_id);
1354 has_references = true;
1355 has_type_references |= reference.is_type();
1356 has_value_references |= reference.is_value();
1357 }
1358 }
1359 found_binding.then_some((has_references, has_type_references, has_value_references))
1360}
1361
1362fn classify_import_equals_bindings(
1378 scoping: &oxc_semantic::Scoping,
1379 import_equals_bindings: &[String],
1380 template_used: &rustc_hash::FxHashSet<String>,
1381 type_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1382 value_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1383) -> ImportEqualsClassification {
1384 if import_equals_bindings.is_empty() {
1385 return ImportEqualsClassification::default();
1386 }
1387
1388 let mut classification = ImportEqualsClassification::default();
1389 for local_name in import_equals_bindings {
1390 if local_name.is_empty() {
1391 continue;
1392 }
1393 let Some((has_references, has_type_references, has_value_references)) =
1394 binding_reference_usage(scoping, local_name)
1395 else {
1396 continue;
1397 };
1398 if !has_references {
1399 if !template_used.contains(local_name) {
1400 classification.unreferenced.push(local_name.clone());
1401 }
1402 continue;
1403 }
1404 if has_type_references {
1405 type_referenced_bindings.insert(local_name.clone());
1406 }
1407 if has_value_references {
1408 value_referenced_bindings.insert(local_name.clone());
1409 }
1410 }
1411 classification
1412}
1413
1414#[derive(Clone, Copy, PartialEq, Eq)]
1415enum MergeDeclarationKind {
1416 Interface,
1417 Class,
1418 Function,
1419 Enum,
1420 Namespace,
1421}
1422
1423fn declaration_merge_facts(
1424 semantic: &oxc_semantic::Semantic<'_>,
1425) -> Vec<fallow_types::extract::DeclarationMergeFact> {
1426 use fallow_types::extract::DeclarationMergeFact;
1427
1428 let scoping = semantic.scoping();
1429 let mut groups = Vec::new();
1430 for symbol_id in scoping.symbol_ids() {
1431 let declarations: Vec<_> = scoping
1432 .symbol_declarations(symbol_id)
1433 .filter_map(|node_id| merge_declaration(semantic.nodes().kind(node_id)))
1434 .collect();
1435 if declarations.len() < 2 {
1436 continue;
1437 }
1438 let mut selected = Vec::new();
1439 for (index, (kind, span)) in declarations.iter().enumerate() {
1440 if declarations
1444 .iter()
1445 .enumerate()
1446 .any(|(other_index, (other, _))| {
1447 other_index != index && compatible_merge(*kind, *other)
1448 })
1449 {
1450 selected.push((span.start, span.end));
1451 }
1452 }
1453 selected.sort_unstable();
1454 selected.dedup();
1455 if selected.len() > 1 {
1456 groups.push(DeclarationMergeFact {
1457 export_spans: selected,
1458 });
1459 }
1460 }
1461 groups.sort_unstable_by_key(|group| group.export_spans[0]);
1462 groups
1463}
1464
1465fn merge_declaration(kind: AstKind<'_>) -> Option<(MergeDeclarationKind, Span)> {
1466 match kind {
1467 AstKind::TSInterfaceDeclaration(declaration) => {
1468 Some((MergeDeclarationKind::Interface, declaration.id.span))
1469 }
1470 AstKind::Class(declaration) => declaration
1471 .id
1472 .as_ref()
1473 .map(|id| (MergeDeclarationKind::Class, id.span)),
1474 AstKind::Function(declaration) => declaration
1475 .id
1476 .as_ref()
1477 .map(|id| (MergeDeclarationKind::Function, id.span)),
1478 AstKind::TSEnumDeclaration(declaration) if !declaration.r#const => {
1479 Some((MergeDeclarationKind::Enum, declaration.id.span))
1480 }
1481 AstKind::TSModuleDeclaration(declaration) => match &declaration.id {
1482 oxc_ast::ast::TSModuleDeclarationName::Identifier(id) => {
1483 Some((MergeDeclarationKind::Namespace, id.span))
1484 }
1485 oxc_ast::ast::TSModuleDeclarationName::StringLiteral(_) => None,
1486 },
1487 _ => None,
1488 }
1489}
1490
1491const fn compatible_merge(left: MergeDeclarationKind, right: MergeDeclarationKind) -> bool {
1492 use MergeDeclarationKind::{Class, Enum, Function, Interface, Namespace};
1493
1494 matches!(
1495 (left, right),
1496 (Interface, Interface | Class | Namespace)
1497 | (Class, Interface | Namespace)
1498 | (Function, Namespace)
1499 | (Enum, Enum | Namespace)
1500 | (Namespace, Interface | Class | Function | Enum | Namespace)
1501 )
1502}
1503
1504fn compute_mock_api_reference_spans(
1505 semantic: &oxc_semantic::Semantic<'_>,
1506 imports: &[ImportInfo],
1507 root_scope: oxc_semantic::ScopeId,
1508) -> MockApiReferenceSpans {
1509 let scoping = semantic.scoping();
1510 let mut spans = MockApiReferenceSpans::default();
1511
1512 let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
1513 let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
1514 else {
1515 return;
1516 };
1517 out.extend(
1518 scoping
1519 .get_resolved_references(symbol_id)
1520 .filter_map(|reference| {
1521 let AstKind::IdentifierReference(identifier) =
1522 semantic.nodes().kind(reference.node_id())
1523 else {
1524 return None;
1525 };
1526 Some(identifier.span)
1527 }),
1528 );
1529 };
1530
1531 for import in imports {
1532 if import.is_type_only || import.local_name.is_empty() {
1533 continue;
1534 }
1535 let is_vi_binding = import.source == "vitest"
1536 && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
1537 let is_jest_binding = import.source == "@jest/globals"
1538 && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
1539 let is_vitest_namespace =
1540 import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
1541
1542 if is_vi_binding || is_jest_binding {
1543 collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
1544 } else if is_vitest_namespace {
1545 collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
1546 }
1547 }
1548
1549 for (name, reference_ids) in scoping.root_unresolved_references() {
1557 if name.as_str() != "jest" {
1558 continue;
1559 }
1560 spans
1561 .mock_bindings
1562 .extend(reference_ids.iter().filter_map(|reference_id| {
1563 let reference = scoping.get_reference(*reference_id);
1564 if !reference.is_value() {
1565 return None;
1566 }
1567 let AstKind::IdentifierReference(identifier) =
1568 semantic.nodes().kind(reference.node_id())
1569 else {
1570 return None;
1571 };
1572 Some(identifier.span)
1573 }));
1574 }
1575
1576 spans
1577}
1578
1579fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1580 use rustc_hash::FxHashSet;
1581
1582 let mut candidates: FxHashSet<String> = FxHashSet::default();
1583 for (name, reference_ids) in scoping.root_unresolved_references() {
1584 if reference_ids
1585 .iter()
1586 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1587 {
1588 candidates.insert(name.as_str().to_string());
1589 }
1590 }
1591
1592 let mut candidates: Vec<String> = candidates.into_iter().collect();
1593 candidates.sort_unstable();
1594 candidates
1595}
1596
1597pub fn compute_import_binding_usage(
1620 program: &Program<'_>,
1621 imports: &[ImportInfo],
1622 import_equals_bindings: &[String],
1623 template_used: &rustc_hash::FxHashSet<String>,
1624) -> ImportBindingUsage {
1625 let mut semantic_usage = compute_semantic_usage_with_candidates(
1626 program,
1627 imports,
1628 import_equals_bindings,
1629 template_used,
1630 &rustc_hash::FxHashSet::default(),
1631 );
1632 report_unreferenced_import_equals_bindings(&mut semantic_usage, &[]);
1636 semantic_usage.import_binding_usage
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641 use super::{
1642 advance_jsdoc_brace_stack, classify_jsdoc_visibility_tag, parse_source_to_module,
1643 scan_jsdoc_imports_in,
1644 };
1645 use fallow_types::discover::FileId;
1646 use fallow_types::extract::{ImportInfo, ImportedName, VisibilityTag};
1647 use std::path::Path;
1648
1649 #[test]
1650 fn classify_jsdoc_visibility_tag_requires_a_bare_tag() {
1651 let cases = [
1652 (" * @public", Some(VisibilityTag::Public)),
1653 (" * @api public", Some(VisibilityTag::Public)),
1654 (" * @publicly", None),
1655 (" * @apipublic", None),
1656 (" * public", None),
1657 (" * @internal", Some(VisibilityTag::Internal)),
1658 (" * @internal-only", Some(VisibilityTag::Internal)),
1659 (" * @internalizer", None),
1660 (" * @internalFoo", None),
1661 (" * internal", None),
1662 (" * @beta", Some(VisibilityTag::Beta)),
1663 (" * @betaware", None),
1664 (" * @beta_x", None),
1665 (" * beta", None),
1666 ("@alpha", Some(VisibilityTag::Alpha)),
1667 ("@alpha Some description", Some(VisibilityTag::Alpha)),
1668 ("@alphabet", None),
1669 (" * alpha", None),
1670 (" * @expected-unused", Some(VisibilityTag::ExpectedUnused)),
1671 (" * @expected-unusedX", None),
1672 ];
1673 for (text, expected) in cases {
1674 let actual = classify_jsdoc_visibility_tag(text).map(|(tag, _)| tag);
1675 assert_eq!(actual, expected, "{text:?}");
1676 }
1677 }
1678
1679 #[test]
1680 fn classify_jsdoc_visibility_tag_keeps_the_expected_unused_reason() {
1681 assert_eq!(
1682 classify_jsdoc_visibility_tag(" * @expected-unused -- kept for the plugin API"),
1683 Some((
1684 VisibilityTag::ExpectedUnused,
1685 Some("kept for the plugin API".to_string())
1686 ))
1687 );
1688 assert_eq!(
1689 classify_jsdoc_visibility_tag(" * @expected-unused"),
1690 Some((VisibilityTag::ExpectedUnused, None))
1691 );
1692 }
1693
1694 fn scan(body: &str) -> Vec<ImportInfo> {
1695 let mut imports = Vec::new();
1696 scan_jsdoc_imports_in(body, &mut imports);
1697 imports
1698 }
1699
1700 #[test]
1701 fn scan_jsdoc_single_import_with_member() {
1702 let imports = scan(" * @param foo {import('./types').Foo}");
1703 assert_eq!(imports.len(), 1);
1704 assert_eq!(imports[0].source, "./types");
1705 assert_eq!(
1706 imports[0].imported_name,
1707 ImportedName::Named("Foo".to_string())
1708 );
1709 assert!(imports[0].is_type_only);
1710 assert!(imports[0].local_name.is_empty());
1711 }
1712
1713 #[test]
1714 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1715 let info = parse_source_to_module(
1716 FileId(0),
1717 Path::new("pages/index.ts"),
1718 r"
1719 useCounter();
1720 const price = formatPrice(10);
1721 const localOnly = () => null;
1722 localOnly();
1723 type Local = UseTypeOnly;
1724 ",
1725 0,
1726 false,
1727 );
1728
1729 assert!(
1730 info.auto_import_candidates
1731 .contains(&"formatPrice".to_string())
1732 );
1733 assert!(
1734 info.auto_import_candidates
1735 .contains(&"useCounter".to_string())
1736 );
1737 assert!(
1738 !info
1739 .auto_import_candidates
1740 .contains(&"UseTypeOnly".to_string())
1741 );
1742 assert!(
1743 !info
1744 .auto_import_candidates
1745 .contains(&"localOnly".to_string())
1746 );
1747 }
1748
1749 #[test]
1750 fn script_auto_import_candidates_skip_explicit_imports() {
1751 let info = parse_source_to_module(
1752 FileId(0),
1753 Path::new("pages/index.ts"),
1754 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1755 0,
1756 false,
1757 );
1758
1759 assert!(
1760 !info
1761 .auto_import_candidates
1762 .contains(&"useCounter".to_string())
1763 );
1764 assert!(
1765 info.auto_import_candidates
1766 .contains(&"useOther".to_string())
1767 );
1768 }
1769
1770 #[test]
1771 fn scan_jsdoc_double_quoted_path() {
1772 let imports = scan(r#" * @type {import("./types").Foo}"#);
1773 assert_eq!(imports.len(), 1);
1774 assert_eq!(imports[0].source, "./types");
1775 }
1776
1777 #[test]
1778 fn scan_jsdoc_multiple_imports_in_same_body() {
1779 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1780 assert_eq!(imports.len(), 2);
1781 assert_eq!(imports[0].source, "./a");
1782 assert_eq!(imports[1].source, "./b");
1783 }
1784
1785 #[test]
1786 fn scan_jsdoc_union_annotation_captures_both_members() {
1787 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1788 assert_eq!(imports.len(), 2);
1789 assert_eq!(
1790 imports[0].imported_name,
1791 ImportedName::Named("A".to_string())
1792 );
1793 assert_eq!(
1794 imports[1].imported_name,
1795 ImportedName::Named("B".to_string())
1796 );
1797 }
1798
1799 #[test]
1800 fn scan_jsdoc_nested_member_uses_first_segment() {
1801 let imports = scan(" * @type {import('./types').ns.Foo}");
1802 assert_eq!(imports.len(), 1);
1803 assert_eq!(
1804 imports[0].imported_name,
1805 ImportedName::Named("ns".to_string())
1806 );
1807 }
1808
1809 #[test]
1810 fn scan_jsdoc_parent_relative_path() {
1811 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1812 assert_eq!(imports.len(), 1);
1813 assert_eq!(imports[0].source, "../lib/types.js");
1814 }
1815
1816 #[test]
1817 fn scan_jsdoc_bare_package_specifier() {
1818 let imports = scan(" * @type {import('@scope/pkg').Client}");
1819 assert_eq!(imports.len(), 1);
1820 assert_eq!(imports[0].source, "@scope/pkg");
1821 assert_eq!(
1822 imports[0].imported_name,
1823 ImportedName::Named("Client".to_string())
1824 );
1825 }
1826
1827 #[test]
1828 fn scan_jsdoc_without_member_is_side_effect() {
1829 let imports = scan(" * @type {import('./types')}");
1830 assert_eq!(imports.len(), 1);
1831 assert_eq!(imports[0].source, "./types");
1832 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1833 assert!(imports[0].is_type_only);
1834 }
1835
1836 #[test]
1837 fn scan_jsdoc_empty_path_is_skipped() {
1838 let imports = scan(" * @type {import('').Foo}");
1839 assert!(imports.is_empty());
1840 }
1841
1842 #[test]
1843 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1844 let imports = scan(" * @type {import('./truncated");
1845 assert!(imports.is_empty());
1846 }
1847
1848 #[test]
1849 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1850 let imports = scan(" * @type {import('./types'.Foo}");
1851 assert!(imports.is_empty());
1852 }
1853
1854 #[test]
1855 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1856 let imports = scan(" * @type {import('./types') .Foo}");
1857 assert_eq!(imports.len(), 1);
1858 assert_eq!(imports[0].source, "./types");
1859 assert_eq!(
1860 imports[0].imported_name,
1861 ImportedName::Named("Foo".to_string())
1862 );
1863 }
1864
1865 #[test]
1866 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1867 let imports = scan(" * @type {import( './types').Foo}");
1868 assert_eq!(imports.len(), 1);
1869 assert_eq!(imports[0].source, "./types");
1870 }
1871
1872 #[test]
1873 fn scan_jsdoc_non_quote_after_paren_skipped() {
1874 let imports = scan(" * @type {import(foo).Bar}");
1875 assert!(imports.is_empty());
1876 }
1877
1878 #[test]
1879 fn scan_jsdoc_ignores_prose_with_import_word() {
1880 let imports = scan(" * This is an important note about imports.");
1881 assert!(imports.is_empty());
1882 }
1883
1884 #[test]
1885 fn scan_jsdoc_utf8_path_works() {
1886 let imports = scan(" * @type {import('./héllo').Foo}");
1887 assert_eq!(imports.len(), 1);
1888 assert_eq!(imports[0].source, "./héllo");
1889 }
1890
1891 #[test]
1892 fn scan_jsdoc_empty_body_is_empty() {
1893 assert!(scan("").is_empty());
1894 }
1895
1896 #[test]
1897 fn scan_jsdoc_no_import_in_body_is_empty() {
1898 assert!(scan(" * @param foo The foo parameter").is_empty());
1899 }
1900
1901 #[test]
1907 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1908 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1911 let imports = scan(body);
1912 assert!(
1913 imports.is_empty(),
1914 "prose import() should not be matched; got: {:?}",
1915 imports
1916 .iter()
1917 .map(|i| i.source.as_str())
1918 .collect::<Vec<_>>()
1919 );
1920 }
1921
1922 #[test]
1923 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1924 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1925 let imports = scan(body);
1926 assert!(
1927 imports.is_empty(),
1928 "object-literal example import() should not be matched; got: {:?}",
1929 imports
1930 .iter()
1931 .map(|i| i.source.as_str())
1932 .collect::<Vec<_>>()
1933 );
1934 }
1935
1936 #[test]
1937 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1938 let imports = scan(" * Use {import('./prose')} as an example string.");
1939 assert!(imports.is_empty());
1940 }
1941
1942 #[test]
1943 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1944 let imports = scan("\n * @example\n * { import('./prose') }\n");
1945 assert!(imports.is_empty());
1946 }
1947
1948 #[test]
1952 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1953 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1954 let imports = scan(body);
1955 assert_eq!(imports.len(), 1, "got: {imports:?}");
1956 assert_eq!(imports[0].source, "./real");
1957 assert_eq!(
1958 imports[0].imported_name,
1959 ImportedName::Named("Foo".to_string())
1960 );
1961 }
1962
1963 #[test]
1964 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1965 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1966 let imports = scan(body);
1967 assert_eq!(imports.len(), 1, "got: {imports:?}");
1968 assert_eq!(imports[0].source, "./real");
1969 assert_eq!(
1970 imports[0].imported_name,
1971 ImportedName::Named("Foo".to_string())
1972 );
1973 }
1974
1975 #[test]
1976 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1977 let body = "\n * @type\n * { import('./real').Foo }\n";
1978 let imports = scan(body);
1979 assert_eq!(imports.len(), 1, "got: {imports:?}");
1980 assert_eq!(imports[0].source, "./real");
1981 assert_eq!(
1982 imports[0].imported_name,
1983 ImportedName::Named("Foo".to_string())
1984 );
1985 }
1986
1987 #[test]
1988 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1989 let imports = scan(" * @satisfies {import('./real').Foo}");
1990 assert_eq!(imports.len(), 1, "got: {imports:?}");
1991 assert_eq!(imports[0].source, "./real");
1992 assert_eq!(
1993 imports[0].imported_name,
1994 ImportedName::Named("Foo".to_string())
1995 );
1996 }
1997
1998 #[test]
1999 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
2000 let imports = scan(" * @template {import('./real').Foo} T");
2001 assert_eq!(imports.len(), 1, "got: {imports:?}");
2002 assert_eq!(imports[0].source, "./real");
2003 assert_eq!(
2004 imports[0].imported_name,
2005 ImportedName::Named("Foo".to_string())
2006 );
2007 }
2008
2009 #[test]
2010 fn scan_jsdoc_enum_type_tag_is_still_matched() {
2011 let imports = scan(" * @enum {import('./real').Foo}");
2012 assert_eq!(imports.len(), 1, "got: {imports:?}");
2013 assert_eq!(imports[0].source, "./real");
2014 assert_eq!(
2015 imports[0].imported_name,
2016 ImportedName::Named("Foo".to_string())
2017 );
2018 }
2019
2020 #[test]
2021 fn scan_jsdoc_appends_to_existing_imports() {
2022 let mut imports = vec![ImportInfo {
2023 source: "existing".to_string(),
2024 imported_name: ImportedName::Default,
2025 local_name: "existing".to_string(),
2026 is_type_only: false,
2027 is_type_only_star: false,
2028 from_style: false,
2029 span: oxc_span::Span::default(),
2030 source_span: oxc_span::Span::default(),
2031 }];
2032 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
2033 assert_eq!(imports.len(), 2);
2034 assert_eq!(imports[0].source, "existing");
2035 assert_eq!(imports[1].source, "./new");
2036 }
2037
2038 #[test]
2039 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
2040 let imports = scan(" * @type {import('./t').Abc}");
2041 assert_eq!(imports.len(), 1);
2042 assert_eq!(
2043 imports[0].imported_name,
2044 ImportedName::Named("Abc".to_string())
2045 );
2046 }
2047
2048 #[test]
2049 fn scan_jsdoc_empty_member_name_is_skipped() {
2050 let imports = scan(" * @type {import('./x').}");
2051 assert!(imports.is_empty());
2052 }
2053
2054 #[test]
2055 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
2056 use std::fmt::Write as _;
2062 let mut body = String::from("/**\n");
2063 for i in 0..200 {
2064 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
2065 }
2066 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
2069 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
2070 body.push_str(" */\n");
2071
2072 let imports = scan(&body);
2073 assert_eq!(imports.len(), 201, "got: {imports:?}");
2074 for (i, import) in imports.iter().take(200).enumerate() {
2075 assert_eq!(import.source, format!("./m{i}"));
2076 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
2077 assert!(import.is_type_only);
2078 assert!(import.local_name.is_empty());
2079 }
2080 assert_eq!(imports[200].source, "./deep");
2082 assert_eq!(
2083 imports[200].imported_name,
2084 ImportedName::Named("D".to_string())
2085 );
2086 }
2087
2088 #[test]
2089 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
2090 let cases = [
2094 " * @type {import('./a').A} and {plain} then {import('./b').B}",
2095 " * @remarks { import('./skip') } @param x {import('./c').C}",
2096 " * text } stray close { import('./d').D } trailing",
2097 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
2098 ];
2099 for body in cases {
2100 let bytes = body.as_bytes();
2101 let mut cursor = 0;
2102 while let Some(rel) = body[cursor..].find("import(") {
2103 let import_pos = cursor + rel;
2104 let mut fresh = Vec::new();
2106 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
2107 match b {
2108 b'{' => fresh.push(idx),
2109 b'}' => {
2110 fresh.pop();
2111 }
2112 _ => {}
2113 }
2114 }
2115 let mut stack = Vec::new();
2116 let mut scanned = 0;
2117 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
2118 assert_eq!(
2119 stack.last().copied(),
2120 fresh.last().copied(),
2121 "enclosing brace mismatch at {import_pos} in {body:?}"
2122 );
2123 cursor = import_pos + "import(".len();
2124 }
2125 }
2126 }
2127}