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