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 if is_glimmer_file(path) {
91 for range in crate::glimmer::find_template_ranges(source) {
92 let (start, end) = (range.start, range.end);
93 let mut callers = crate::sfc_template::component_contracts::collect(
94 &source[start..end],
95 &module.imports,
96 fallow_types::extract::ComponentFramework::Ember,
97 start as u32,
98 module
99 .component_contracts
100 .as_deref()
101 .map_or(&[], |facts| facts.spread_bindings.as_slice()),
102 module
103 .component_contracts
104 .as_deref()
105 .map_or(&[], |facts| facts.aliases.as_slice()),
106 );
107 let (reads, incomplete, dynamic) =
108 crate::sfc_template::glimmer::collect_argument_reads(&source[start..end]);
109 if dynamic {
110 callers
111 .incomplete_frameworks
112 .push(fallow_types::extract::ComponentFramework::Ember);
113 }
114 if let Some(facts) = &mut module.component_contracts {
115 let owners: Vec<_> = facts
116 .template_owners
117 .iter()
118 .filter(|owner| owner.start <= start as u32 && end as u32 <= owner.end)
119 .collect();
120 if let [owner] = owners.as_slice() {
121 for declaration in &mut facts.declarations {
122 if declaration.component_span == owner.component_span
123 && declaration.framework
124 == fallow_types::extract::ComponentFramework::Ember
125 {
126 declaration.is_used |= reads.contains(&declaration.name);
127 declaration.incomplete |= incomplete;
128 }
129 }
130 }
131 }
132 crate::component_contracts::merge(&mut module.component_contracts, callers, 0, None);
133 }
134 }
135 module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
136 module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
137 let federation_facts =
138 crate::federation_runtime::extract_federation_runtime_facts(path, source);
139 if !federation_facts.is_empty() {
140 module.semantic_facts = module
141 .semantic_facts
142 .iter()
143 .cloned()
144 .chain(federation_facts)
145 .collect();
146 }
147 if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
151 module.load_return_keys = Vec::new();
152 module.has_unharvestable_load = false;
153 }
154 module
155}
156
157fn is_sveltekit_page_load_file(path: &Path) -> bool {
162 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
163 return false;
164 };
165 matches!(
166 name,
167 "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
168 )
169}
170
171fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
172 if is_sveltekit_page_load_file(path) {
173 return RouteLoadHarvestMode::SvelteKitPage;
174 }
175 if is_conventional_route_loader_file(path) {
176 return RouteLoadHarvestMode::ConventionalRoute;
177 }
178 RouteLoadHarvestMode::None
179}
180
181fn is_conventional_route_loader_file(path: &Path) -> bool {
182 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
183 return false;
184 };
185 if name.starts_with('+') {
186 return false;
187 }
188 if !matches!(
189 path.extension().and_then(|ext| ext.to_str()),
190 Some("ts" | "tsx" | "js" | "jsx")
191 ) {
192 return false;
193 }
194 if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
195 && path
196 .parent()
197 .and_then(|parent| parent.file_name())
198 .and_then(|part| part.to_str())
199 .is_some_and(|part| matches!(part, "app" | "src"))
200 {
201 return true;
202 }
203 path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
204}
205
206fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
207 let mut previous = None;
208 for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
209 if previous == Some(app_dir) && part == "routes" {
210 return true;
211 }
212 previous = Some(part);
213 }
214 false
215}
216
217fn parse_source_to_module_inner(
218 file_id: FileId,
219 path: &Path,
220 source: &str,
221 content_hash: u64,
222 need_complexity: bool,
223 flag_patterns: &FlagPatterns,
224) -> ModuleInfo {
225 let source = crate::strip_bom(source);
226 if let Some(module) =
227 parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
228 {
229 return module;
230 }
231
232 let stripped_glimmer_source = is_glimmer_file(path)
233 .then(|| strip_glimmer_templates(source))
234 .flatten();
235 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
236 let source_type = source_type_for_path(path);
237 let allocator = Allocator::default();
238 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
239 let mut degradation = ParseDegradation::from_parser(&parser_return);
240
241 let mut parsed_suppressions =
242 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
243
244 let (mut extractor, mut semantic_usage) =
245 build_primary_extractor(&parser_return.program, path, source, source_type);
246
247 let line_offsets = fallow_types::extract::compute_line_offsets(source);
248
249 let (mut complexity, mut flags) = compute_primary_complexity_and_flags(
250 &parser_return.program,
251 parser_source,
252 &extractor.inline_template_findings,
253 &line_offsets,
254 need_complexity,
255 flag_patterns,
256 );
257
258 apply_jsx_retry_or_jsdoc(
259 &JsxRetryOrJsdocInput {
260 path,
261 parser_source,
262 source_type,
263 need_complexity,
264 line_offsets: &line_offsets,
265 comments: &parser_return.program.comments,
266 source,
267 export_statements: &crate::jsdoc_attach::export_statement_spans(&parser_return.program),
268 flag_patterns,
269 },
270 &mut ParseOutputs {
271 extractor: &mut extractor,
272 semantic_usage: &mut semantic_usage,
273 complexity: &mut complexity,
274 flags: &mut flags,
275 parsed_suppressions: &mut parsed_suppressions,
276 degradation: &mut degradation,
277 },
278 );
279
280 assemble_module_info(ModuleAssemblyInput {
281 extractor,
282 file_id,
283 content_hash,
284 parsed_suppressions,
285 semantic_usage,
286 line_offsets,
287 complexity,
288 flags,
289 degradation,
290 })
291}
292
293struct JsxRetryOrJsdocInput<'a> {
295 path: &'a Path,
296 parser_source: &'a str,
297 source_type: SourceType,
298 need_complexity: bool,
299 line_offsets: &'a [u32],
300 comments: &'a [Comment],
301 source: &'a str,
302 export_statements: &'a [oxc_span::Span],
303 flag_patterns: &'a FlagPatterns,
304}
305
306struct ModuleAssemblyInput {
307 extractor: ModuleInfoExtractor,
308 file_id: FileId,
309 content_hash: u64,
310 parsed_suppressions: crate::suppress::ParsedSuppressions,
311 semantic_usage: SemanticUsage,
312 line_offsets: Vec<u32>,
313 complexity: Vec<FunctionComplexity>,
314 flags: ExtractedFlags,
315 degradation: ParseDegradation,
316}
317
318#[derive(Debug, Clone, Copy, Default)]
327struct ParseDegradation {
328 error_count: u32,
329 panicked: bool,
330}
331
332impl ParseDegradation {
333 fn from_parser(parser_return: &oxc_parser::ParserReturn<'_>) -> Self {
334 Self {
335 error_count: u32::try_from(parser_return.diagnostics.len()).unwrap_or(u32::MAX),
336 panicked: parser_return.fatal_error,
337 }
338 }
339}
340
341fn build_primary_extractor(
344 program: &Program<'_>,
345 path: &Path,
346 source: &str,
347 source_type: SourceType,
348) -> (ModuleInfoExtractor, SemanticUsage) {
349 let mut extractor = ModuleInfoExtractor::new();
350 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
351 extractor.jsx_capable = source_type.is_jsx();
355 extractor.visit_program(program);
356 extractor.resolve_pending_local_export_specifiers();
357
358 let template_used_imports =
359 collect_glimmer_template_into_extractor(&mut extractor, path, source);
360 let semantic_usage =
361 compute_semantic_usage_for_extractor(program, &mut extractor, &template_used_imports);
362 extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
363 (extractor, semantic_usage)
364}
365
366fn compute_primary_complexity_and_flags(
369 program: &Program<'_>,
370 parser_source: &str,
371 inline_template_findings: &[crate::visitor::InlineTemplateFinding],
372 line_offsets: &[u32],
373 need_complexity: bool,
374 flag_patterns: &FlagPatterns,
375) -> (Vec<FunctionComplexity>, ExtractedFlags) {
376 let mut complexity = if need_complexity {
377 crate::complexity::compute_complexity(program, parser_source, line_offsets)
378 } else {
379 Vec::new()
380 };
381 if need_complexity {
382 append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
383 }
384
385 let flags = crate::flags::extract_flags(program, line_offsets, flag_patterns);
386 (complexity, flags)
387}
388
389struct ParseOutputs<'a> {
391 extractor: &'a mut ModuleInfoExtractor,
392 semantic_usage: &'a mut SemanticUsage,
393 complexity: &'a mut Vec<FunctionComplexity>,
394 flags: &'a mut ExtractedFlags,
395 parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
396 degradation: &'a mut ParseDegradation,
397}
398
399fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
403 let retry_input = JsxRetryInput {
404 path: input.path,
405 source: input.source,
406 parser_source: input.parser_source,
407 source_type: input.source_type,
408 total_extracted: outputs.extractor.exports.len()
409 + outputs.extractor.imports.len()
410 + outputs.extractor.re_exports.len(),
411 need_complexity: input.need_complexity,
412 line_offsets: input.line_offsets,
413 flag_patterns: input.flag_patterns,
414 };
415 let Some(retry) = parse_with_jsx_retry(&retry_input) else {
416 apply_jsdoc_tags_to_extractor(
417 &mut *outputs.extractor,
418 input.comments,
419 input.source,
420 input.export_statements,
421 );
422 return;
423 };
424 *outputs.extractor = retry.extractor;
425 *outputs.semantic_usage = retry.semantic_usage;
426 *outputs.complexity = retry.complexity;
427 *outputs.flags = retry.flags;
428 *outputs.parsed_suppressions = retry.parsed_suppressions;
429 *outputs.degradation = retry.degradation;
432}
433
434fn apply_jsdoc_tags_to_extractor(
437 extractor: &mut ModuleInfoExtractor,
438 comments: &[Comment],
439 source: &str,
440 statements: &[oxc_span::Span],
441) {
442 apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source, statements);
443 crate::jsdoc_deprecated::apply_jsdoc_deprecated_tags(
444 &mut extractor.exports,
445 comments,
446 source,
447 statements,
448 );
449 extract_jsdoc_import_types(&mut extractor.imports, comments, source);
450}
451
452fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
455 let ModuleAssemblyInput {
456 extractor,
457 file_id,
458 content_hash,
459 parsed_suppressions,
460 semantic_usage,
461 line_offsets,
462 complexity,
463 flags,
464 degradation,
465 } = input;
466 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
467 let mut contracts = semantic_usage.component_contracts;
468 if degradation.error_count > 0 || degradation.panicked {
469 for declaration in &mut contracts.declarations {
470 declaration.incomplete = true;
471 }
472 for invocation in &mut contracts.invocations {
473 invocation.unknown_props = true;
474 }
475 }
476 if !contracts.aliases.is_empty()
477 || !contracts.exports.is_empty()
478 || !contracts.declarations.is_empty()
479 || !contracts.invocations.is_empty()
480 || !contracts.escapes.is_empty()
481 || !contracts.incomplete_frameworks.is_empty()
482 || !contracts.spread_bindings.is_empty()
483 {
484 info.component_contracts = Some(Box::new(contracts));
485 }
486 info.parse_error_count = degradation.error_count;
487 info.parse_panicked = degradation.panicked;
488 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
489 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
490 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
491 info.auto_import_candidates
492 .extend(semantic_usage.auto_import_candidates);
493 info.auto_import_candidates.sort_unstable();
494 info.auto_import_candidates.dedup();
495 append_declaration_merge_facts(
496 &mut info.semantic_facts,
497 semantic_usage.declaration_merges,
498 0,
499 );
500 info.line_offsets = line_offsets;
501 info.complexity = complexity;
502 info.flag_uses = flags.flag_uses;
503 info.flag_registry_facts = flags.registry_facts;
504 info
505}
506
507pub fn append_declaration_merge_facts(
508 facts: &mut std::sync::Arc<[fallow_types::extract::SemanticFact]>,
509 mut groups: Vec<fallow_types::extract::DeclarationMergeFact>,
510 byte_offset: u32,
511) {
512 if groups.is_empty() {
513 return;
514 }
515 if byte_offset != 0 {
516 for group in &mut groups {
517 for (start, end) in &mut group.export_spans {
518 *start += byte_offset;
519 *end += byte_offset;
520 }
521 }
522 }
523 let mut merged = std::mem::take(facts).to_vec();
524 merged.extend(
525 groups
526 .into_iter()
527 .map(fallow_types::extract::SemanticFact::DeclarationMerge),
528 );
529 *facts = merged.into();
530}
531
532struct JsxRetryInput<'a> {
533 path: &'a Path,
534 source: &'a str,
535 parser_source: &'a str,
536 source_type: SourceType,
537 total_extracted: usize,
538 need_complexity: bool,
539 line_offsets: &'a [u32],
540 flag_patterns: &'a FlagPatterns,
541}
542
543fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
544 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
545 return None;
546 }
547
548 let jsx_type = if input.source_type.is_typescript() {
549 SourceType::tsx()
550 } else {
551 SourceType::jsx()
552 };
553 let allocator = Allocator::default();
554 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
555 let degradation = ParseDegradation::from_parser(&retry_return);
556 let mut extractor = ModuleInfoExtractor::new();
557 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
558 extractor.jsx_capable = true;
561 extractor.visit_program(&retry_return.program);
562 extractor.resolve_pending_local_export_specifiers();
563 let retry_total =
564 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
565 if retry_total <= input.total_extracted {
566 return None;
567 }
568
569 let template_used_imports =
570 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
571 let semantic_usage = compute_semantic_usage_for_extractor(
572 &retry_return.program,
573 &mut extractor,
574 &template_used_imports,
575 );
576 extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
577 let complexity = retry_complexity(
578 input.need_complexity,
579 &retry_return.program,
580 input.parser_source,
581 input.line_offsets,
582 &extractor,
583 );
584 let flags = crate::flags::extract_flags(
585 &retry_return.program,
586 input.line_offsets,
587 input.flag_patterns,
588 );
589 let parsed_suppressions =
590 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
591 let export_statements = crate::jsdoc_attach::export_statement_spans(&retry_return.program);
592 apply_jsdoc_visibility_tags(
593 &mut extractor.exports,
594 &retry_return.program.comments,
595 input.source,
596 &export_statements,
597 );
598 crate::jsdoc_deprecated::apply_jsdoc_deprecated_tags(
599 &mut extractor.exports,
600 &retry_return.program.comments,
601 input.source,
602 &export_statements,
603 );
604 extract_jsdoc_import_types(
605 &mut extractor.imports,
606 &retry_return.program.comments,
607 input.source,
608 );
609 Some(JsxRetryParse {
610 extractor,
611 semantic_usage,
612 complexity,
613 flags,
614 parsed_suppressions,
615 degradation,
616 })
617}
618
619fn retry_complexity(
620 need_complexity: bool,
621 program: &Program<'_>,
622 parser_source: &str,
623 line_offsets: &[u32],
624 extractor: &ModuleInfoExtractor,
625) -> Vec<FunctionComplexity> {
626 if !need_complexity {
627 return Vec::new();
628 }
629 let mut complexity =
630 crate::complexity::compute_complexity(program, parser_source, line_offsets);
631 append_inline_template_complexity(
632 &mut complexity,
633 &extractor.inline_template_findings,
634 line_offsets,
635 );
636 complexity
637}
638
639fn parse_non_js_source_to_module(
640 file_id: FileId,
641 path: &Path,
642 source: &str,
643 content_hash: u64,
644 need_complexity: bool,
645) -> Option<ModuleInfo> {
646 if is_sfc_file(path) {
647 return Some(parse_sfc_to_module(
648 file_id,
649 path,
650 source,
651 content_hash,
652 need_complexity,
653 ));
654 }
655 if is_astro_file(path) {
656 return Some(parse_astro_to_module(
657 file_id,
658 source,
659 content_hash,
660 need_complexity,
661 ));
662 }
663 if is_mdx_file(path) {
664 return Some(parse_mdx_to_module(file_id, source, content_hash));
665 }
666 if is_css_file(path) {
667 return Some(parse_css_to_module(file_id, path, source, content_hash));
668 }
669 if is_graphql_file(path) {
670 return Some(parse_graphql_to_module(file_id, source, content_hash));
671 }
672 if is_html_file(path) {
673 return Some(parse_html_to_module_with_complexity(
674 file_id,
675 source,
676 content_hash,
677 need_complexity,
678 ));
679 }
680 None
681}
682
683fn collect_glimmer_template_into_extractor(
705 extractor: &mut ModuleInfoExtractor,
706 path: &Path,
707 source: &str,
708) -> rustc_hash::FxHashSet<String> {
709 use rustc_hash::FxHashSet;
710
711 if !is_glimmer_file(path) {
712 return FxHashSet::default();
713 }
714 let template_ranges = crate::glimmer::find_template_ranges(source);
715 if template_ranges.is_empty() {
716 return FxHashSet::default();
717 }
718
719 let imported_bindings: FxHashSet<String> = extractor
720 .imports
721 .iter()
722 .filter(|import| !import.local_name.is_empty())
723 .map(|import| import.local_name.clone())
724 .collect();
725
726 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
727 source,
728 &template_ranges,
729 &imported_bindings,
730 );
731 extractor.member_accesses.extend(usage.member_accesses);
732 usage.used_bindings
733}
734
735fn append_inline_template_complexity(
746 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
747 findings: &[crate::visitor::InlineTemplateFinding],
748 line_offsets: &[u32],
749) {
750 for finding in findings {
751 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
752 &finding.template_source,
753 ) else {
754 continue;
755 };
756 let (line, col) =
757 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
758 fc.line = line;
759 fc.col = col;
760 complexity.push(fc);
761 }
762}
763
764fn apply_jsdoc_visibility_tags(
772 exports: &mut [ExportInfo],
773 comments: &[Comment],
774 source: &str,
775 statements: &[oxc_span::Span],
776) {
777 if exports.is_empty() || comments.is_empty() {
778 return;
779 }
780
781 let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
782 if tag_offsets.is_empty() {
783 return;
784 }
785 tag_offsets.sort_by_key(|&(offset, _, _)| offset);
787
788 for export in exports.iter_mut() {
789 apply_visibility_tag_to_export(export, &tag_offsets, statements);
790 }
791}
792
793fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
796 if has_public_tag(text) {
797 Some((VisibilityTag::Public, None))
798 } else if bare_jsdoc_tag_end(text, "@internal").is_some() {
799 Some((VisibilityTag::Internal, None))
800 } else if bare_jsdoc_tag_end(text, "@alpha").is_some() {
801 Some((VisibilityTag::Alpha, None))
802 } else if bare_jsdoc_tag_end(text, "@beta").is_some() {
803 Some((VisibilityTag::Beta, None))
804 } else {
805 bare_jsdoc_tag_end(text, "@expected-unused").map(|after| {
806 (
807 VisibilityTag::ExpectedUnused,
808 split_jsdoc_reason(&text[after..]),
809 )
810 })
811 }
812}
813
814fn collect_jsdoc_tag_offsets(
817 comments: &[Comment],
818 source: &str,
819) -> Vec<(u32, VisibilityTag, Option<String>)> {
820 let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
821 for comment in comments {
822 if !comment.is_jsdoc() {
823 continue;
824 }
825 let content_span = comment.content_span();
826 let start = content_span.start as usize;
827 let end = (content_span.end as usize).min(source.len());
828 if start >= end {
829 continue;
830 }
831 if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
832 tag_offsets.push((comment.attached_to, tag, reason));
833 }
834 }
835 tag_offsets
836}
837
838fn apply_visibility_tag_to_export(
840 export: &mut ExportInfo,
841 tag_offsets: &[(u32, VisibilityTag, Option<String>)],
842 statements: &[oxc_span::Span],
843) {
844 if export.span.start == 0 && export.span.end == 0 {
845 return;
846 }
847 let found = crate::jsdoc_attach::tag_index_for_export(
848 tag_offsets,
849 |&(offset, _, _)| offset,
850 export.span.start,
851 statements,
852 );
853 if let Some(idx) = found {
854 export.visibility = tag_offsets[idx].1;
855 export
856 .expected_unused_reason
857 .clone_from(&tag_offsets[idx].2);
858 }
859}
860
861fn split_jsdoc_reason(rest: &str) -> Option<String> {
862 for (idx, _) in rest.match_indices("--") {
863 let before_ok = idx == 0
864 || rest[..idx]
865 .chars()
866 .next_back()
867 .is_some_and(char::is_whitespace);
868 let after_idx = idx + 2;
869 let after_ok = after_idx == rest.len()
870 || rest[after_idx..]
871 .chars()
872 .next()
873 .is_some_and(char::is_whitespace);
874 if before_ok && after_ok {
875 let reason = rest[after_idx..].trim();
876 return if reason.is_empty() {
877 None
878 } else {
879 Some(reason.to_string())
880 };
881 }
882 }
883
884 None
885}
886
887const fn is_ident_char(b: u8) -> bool {
889 b.is_ascii_alphanumeric() || b == b'_'
890}
891
892fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
916 if comments.is_empty() {
917 return;
918 }
919
920 let mut namespaces = Vec::new();
921 for comment in comments {
922 if !comment.is_jsdoc() {
923 continue;
924 }
925 let content_span = comment.content_span();
926 let start = content_span.start as usize;
927 let end = (content_span.end as usize).min(source.len());
928 if start >= end {
929 continue;
930 }
931 let body = &source[start..end];
932 scan_jsdoc_imports_in(body, imports);
933 scan_jsdoc_import_tags_in(body, imports, &mut namespaces);
934 }
935 if !namespaces.is_empty() {
936 push_jsdoc_namespace_members(imports, &namespaces, comments, source);
937 }
938}
939
940struct JsdocNamespaceImport {
942 local: String,
943 source: String,
944}
945
946fn push_jsdoc_namespace_members(
954 imports: &mut Vec<ImportInfo>,
955 namespaces: &[JsdocNamespaceImport],
956 comments: &[Comment],
957 source: &str,
958) {
959 use fallow_types::extract::ImportedName;
960
961 let mut members: Vec<(usize, &str)> = Vec::new();
962 for comment in comments.iter().filter(|comment| comment.is_jsdoc()) {
963 let content_span = comment.content_span();
964 let start = content_span.start as usize;
965 let end = (content_span.end as usize).min(source.len());
966 if start >= end {
967 continue;
968 }
969 scan_jsdoc_namespace_members_in(&source[start..end], namespaces, &mut members);
970 }
971 for namespace in namespaces {
972 imports.push(jsdoc_type_import(
973 &namespace.source,
974 ImportedName::SideEffect,
975 ));
976 }
977 for (index, member) in members {
978 imports.push(jsdoc_type_import(
979 &namespaces[index].source,
980 ImportedName::Named(member.to_string()),
981 ));
982 }
983}
984
985fn scan_jsdoc_namespace_members_in<'a>(
989 body: &'a str,
990 namespaces: &[JsdocNamespaceImport],
991 members: &mut Vec<(usize, &'a str)>,
992) {
993 let bytes = body.as_bytes();
994 let mut brace_stack: Vec<usize> = Vec::new();
995 let mut scanned = 0;
996 let mut pos = 0;
997 while pos < bytes.len() {
998 let starts_ident =
999 (bytes[pos].is_ascii_alphabetic() || bytes[pos] == b'_' || bytes[pos] == b'$')
1000 && (pos == 0
1001 || !(is_ident_char(bytes[pos - 1]) || matches!(bytes[pos - 1], b'$' | b'.')));
1002 if !starts_ident {
1003 pos += 1;
1004 continue;
1005 }
1006 let Some((ident, after)) = take_js_identifier(&body[pos..]) else {
1007 pos += 1;
1008 continue;
1009 };
1010 let ident_pos = pos;
1011 pos += ident.len();
1012 let Some(index) = namespaces
1013 .iter()
1014 .position(|namespace| namespace.local == ident)
1015 else {
1016 continue;
1017 };
1018 let Some((member, _)) = after.strip_prefix('.').and_then(take_js_identifier) else {
1019 continue;
1020 };
1021 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, ident_pos);
1022 if !is_inside_jsdoc_type_brace_group(bytes, ident_pos, brace_stack.last().copied()) {
1023 continue;
1024 }
1025 let entry = (index, member);
1026 if !members.contains(&entry) {
1027 members.push(entry);
1028 }
1029 pos += 1 + member.len();
1030 }
1031}
1032
1033const JSDOC_IMPORT_TAG: &str = "@import";
1034
1035fn scan_jsdoc_import_tags_in(
1050 body: &str,
1051 imports: &mut Vec<ImportInfo>,
1052 namespaces: &mut Vec<JsdocNamespaceImport>,
1053) {
1054 let bytes = body.as_bytes();
1055 let mut cursor = 0;
1056 while let Some(rel) = body[cursor..].find(JSDOC_IMPORT_TAG) {
1057 let tag_pos = cursor + rel;
1058 cursor = tag_pos + JSDOC_IMPORT_TAG.len();
1059 let starts_line = strip_jsdoc_line_prefix(line_prefix_before(bytes, tag_pos)).is_empty();
1060 let ends_tag = bytes
1061 .get(cursor)
1062 .is_none_or(|&b| b.is_ascii_whitespace() || b == b'{' || b == b'*');
1063 if !starts_line || !ends_tag {
1064 continue;
1065 }
1066 let clause = jsdoc_import_tag_clause(&body[cursor..]);
1067 let Some(parsed) = parse_jsdoc_import_clause(&clause) else {
1068 continue;
1069 };
1070 if let Some(local) = parsed.namespace {
1071 namespaces.push(JsdocNamespaceImport {
1072 local: local.to_string(),
1073 source: parsed.source.to_string(),
1074 });
1075 } else if parsed.names.is_empty() {
1076 imports.push(jsdoc_type_import(
1077 parsed.source,
1078 fallow_types::extract::ImportedName::SideEffect,
1079 ));
1080 }
1081 for name in parsed.names {
1082 imports.push(jsdoc_type_import(parsed.source, name));
1083 }
1084 }
1085}
1086
1087fn jsdoc_import_tag_clause(rest: &str) -> String {
1090 let mut lines = rest.split('\n');
1091 let mut clause = lines.next().unwrap_or_default().to_string();
1092 for line in lines {
1093 let line = strip_jsdoc_line_prefix(line);
1094 if line.starts_with('@') {
1095 break;
1096 }
1097 clause.push(' ');
1098 clause.push_str(line);
1099 }
1100 clause
1101}
1102
1103struct JsdocImportClause<'a> {
1105 names: Vec<fallow_types::extract::ImportedName>,
1107 namespace: Option<&'a str>,
1109 source: &'a str,
1110}
1111
1112fn parse_jsdoc_import_clause(clause: &str) -> Option<JsdocImportClause<'_>> {
1115 use fallow_types::extract::ImportedName;
1116
1117 let mut names = Vec::new();
1118 let mut namespace = None;
1119 let mut rest = clause.trim_start();
1120 if let Some((ident, after)) = take_js_identifier(rest)
1121 && ident != "from"
1122 {
1123 names.push(ImportedName::Default);
1124 rest = after.trim_start();
1125 match rest.strip_prefix(',') {
1126 Some(after_comma) => rest = after_comma.trim_start(),
1127 None => {
1128 return parse_jsdoc_import_from(rest).map(|source| JsdocImportClause {
1129 names,
1130 namespace,
1131 source,
1132 });
1133 }
1134 }
1135 }
1136 if let Some(after_star) = rest.strip_prefix('*') {
1137 let after_as = after_star.trim_start().strip_prefix("as")?;
1138 let (local, after_ns) = take_js_identifier(after_as.trim_start())?;
1139 namespace = Some(local);
1140 rest = after_ns;
1141 } else if let Some(after_brace) = rest.strip_prefix('{') {
1142 let close = after_brace.find('}')?;
1143 for specifier in after_brace[..close].split(',') {
1144 if let Some(name) = jsdoc_import_specifier_name(specifier) {
1145 names.push(name);
1146 }
1147 }
1148 rest = &after_brace[close + 1..];
1149 } else if names.is_empty() {
1150 return None;
1151 }
1152 parse_jsdoc_import_from(rest.trim_start()).map(|source| JsdocImportClause {
1153 names,
1154 namespace,
1155 source,
1156 })
1157}
1158
1159fn jsdoc_import_specifier_name(specifier: &str) -> Option<fallow_types::extract::ImportedName> {
1162 use fallow_types::extract::ImportedName;
1163
1164 let specifier = specifier.trim();
1165 let specifier = specifier
1166 .strip_prefix("type")
1167 .filter(|after| after.starts_with(char::is_whitespace))
1168 .map_or(specifier, str::trim_start);
1169 let name = match specifier.as_bytes().first()? {
1170 quote @ (b'\'' | b'"') => {
1171 let inner = &specifier[1..];
1172 &inner[..inner.find(*quote as char)?]
1173 }
1174 _ => take_js_identifier(specifier)?.0,
1175 };
1176 if name == "default" {
1177 return Some(ImportedName::Default);
1178 }
1179 Some(ImportedName::Named(name.to_string()))
1180}
1181
1182fn parse_jsdoc_import_from(rest: &str) -> Option<&str> {
1184 let (keyword, after) = take_js_identifier(rest)?;
1185 if keyword != "from" {
1186 return None;
1187 }
1188 let after = after.trim_start();
1189 let quote = after.chars().next().filter(|c| *c == '\'' || *c == '"')?;
1190 let inner = &after[1..];
1191 let source = &inner[..inner.find(quote)?];
1192 (!source.is_empty()).then_some(source)
1193}
1194
1195fn take_js_identifier(text: &str) -> Option<(&str, &str)> {
1198 let bytes = text.as_bytes();
1199 if !bytes
1200 .first()
1201 .is_some_and(|&b| b.is_ascii_alphabetic() || b == b'_' || b == b'$')
1202 {
1203 return None;
1204 }
1205 let end = bytes
1206 .iter()
1207 .position(|&b| !(is_ident_char(b) || b == b'$'))
1208 .unwrap_or(bytes.len());
1209 Some(text.split_at(end))
1210}
1211
1212fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
1220 let bytes = body.as_bytes();
1221 let mut cursor = 0;
1222 let mut brace_stack: Vec<usize> = Vec::new();
1229 let mut scanned = 0;
1230 while let Some(rel) = body[cursor..].find("import(") {
1231 let import_pos = cursor + rel;
1232 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
1233 if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
1234 cursor = import_pos + "import(".len();
1235 continue;
1236 }
1237 let open = import_pos + "import(".len();
1238 match locate_jsdoc_import_path(body, bytes, open) {
1239 JsdocImportScan::Stop => break,
1240 JsdocImportScan::Skip(next) => {
1241 cursor = next;
1242 }
1243 JsdocImportScan::Found { path, after_paren } => {
1244 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
1245 }
1246 }
1247 }
1248}
1249
1250enum JsdocImportScan<'a> {
1253 Stop,
1255 Skip(usize),
1257 Found { path: &'a str, after_paren: usize },
1259}
1260
1261fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
1264 if open >= bytes.len() {
1265 return JsdocImportScan::Stop;
1266 }
1267 let mut i = open;
1268 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1269 i += 1;
1270 }
1271 if i >= bytes.len() {
1272 return JsdocImportScan::Stop;
1273 }
1274 let quote = bytes[i];
1275 if quote != b'\'' && quote != b'"' {
1276 return JsdocImportScan::Skip(open);
1277 }
1278 let path_start = i + 1;
1279 let Some(rel_close) = body[path_start..].find(quote as char) else {
1280 return JsdocImportScan::Stop;
1281 };
1282 let path_end = path_start + rel_close;
1283 let path = &body[path_start..path_end];
1284 if path.is_empty() {
1285 return JsdocImportScan::Skip(path_end + 1);
1286 }
1287 let mut j = path_end + 1;
1288 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1289 j += 1;
1290 }
1291 if j >= bytes.len() || bytes[j] != b')' {
1292 return JsdocImportScan::Skip(path_end + 1);
1293 }
1294 j += 1;
1295 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1296 j += 1;
1297 }
1298 JsdocImportScan::Found {
1299 path,
1300 after_paren: j,
1301 }
1302}
1303
1304fn resolve_jsdoc_import(
1307 body: &str,
1308 bytes: &[u8],
1309 after_paren: usize,
1310 path: &str,
1311 imports: &mut Vec<ImportInfo>,
1312) -> usize {
1313 let mut j = after_paren;
1314 if j >= bytes.len() || bytes[j] != b'.' {
1315 imports.push(jsdoc_type_import(
1316 path,
1317 fallow_types::extract::ImportedName::SideEffect,
1318 ));
1319 return after_paren;
1320 }
1321 j += 1;
1322 let name_start = j;
1323 while j < bytes.len() && is_ident_char(bytes[j]) {
1324 j += 1;
1325 }
1326 if name_start == j {
1327 return after_paren;
1330 }
1331 let member = &body[name_start..j];
1332 imports.push(jsdoc_type_import(
1333 path,
1334 fallow_types::extract::ImportedName::Named(member.to_string()),
1335 ));
1336 j
1337}
1338
1339fn jsdoc_type_import(
1343 source: &str,
1344 imported_name: fallow_types::extract::ImportedName,
1345) -> ImportInfo {
1346 ImportInfo {
1347 source: source.to_string(),
1348 imported_name,
1349 local_name: String::new(),
1350 is_type_only: true,
1351 is_type_only_star: false,
1352 from_style: false,
1353 span: oxc_span::Span::default(),
1354 source_span: oxc_span::Span::default(),
1355 }
1356}
1357
1358fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
1364 let Some(open_brace) = open_brace else {
1365 return false;
1366 };
1367
1368 let prefix = line_prefix_before(body, open_brace);
1369 if jsdoc_line_prefix_has_type_tag(prefix) {
1370 return true;
1371 }
1372
1373 strip_jsdoc_line_prefix(prefix).is_empty()
1374 && preceding_jsdoc_line_has_type_tag(body, open_brace)
1375 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
1376}
1377
1378fn advance_jsdoc_brace_stack(
1388 body: &[u8],
1389 stack: &mut Vec<usize>,
1390 scanned: &mut usize,
1391 up_to: usize,
1392) {
1393 let up_to = up_to.min(body.len());
1394 while *scanned < up_to {
1395 match body[*scanned] {
1396 b'{' => stack.push(*scanned),
1397 b'}' => {
1398 stack.pop();
1399 }
1400 _ => {}
1401 }
1402 *scanned += 1;
1403 }
1404}
1405
1406fn line_prefix_before(body: &[u8], pos: usize) -> &str {
1407 let start = body[..pos]
1408 .iter()
1409 .rposition(|&b| b == b'\n')
1410 .map_or(0, |idx| idx + 1);
1411 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
1412}
1413
1414fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
1415 let trimmed = prefix.trim_start();
1416 trimmed
1417 .strip_prefix('*')
1418 .map_or(trimmed, |rest| rest.trim_start())
1419}
1420
1421fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
1422 const TYPE_TAGS: [&str; 17] = [
1423 "@arg",
1424 "@argument",
1425 "@augments",
1426 "@callback",
1427 "@enum",
1428 "@extends",
1429 "@implements",
1430 "@param",
1431 "@property",
1432 "@prop",
1433 "@return",
1434 "@returns",
1435 "@satisfies",
1436 "@template",
1437 "@this",
1438 "@type",
1439 "@typedef",
1440 ];
1441
1442 let prefix = strip_jsdoc_line_prefix(prefix);
1443 TYPE_TAGS
1444 .iter()
1445 .any(|tag| bare_jsdoc_tag_end(prefix, tag).is_some())
1446}
1447
1448fn bare_jsdoc_tag_end(text: &str, tag: &str) -> Option<usize> {
1451 text.match_indices(tag)
1452 .map(|(idx, _)| idx + tag.len())
1453 .find(|&after| after >= text.len() || !is_ident_char(text.as_bytes()[after]))
1454}
1455
1456fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1457 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1458 return false;
1459 };
1460
1461 let line_start = body[..line_end]
1462 .iter()
1463 .rposition(|&b| b == b'\n')
1464 .map_or(0, |idx| idx + 1);
1465
1466 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1467}
1468
1469fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1470 let mut at_line_start = true;
1471 let mut i = start.min(body.len());
1472 let end = end.min(body.len());
1473 while i < end {
1474 match body[i] {
1475 b'\n' => {
1476 at_line_start = true;
1477 i += 1;
1478 }
1479 b'\r' | b'\t' | b' ' => {
1480 i += 1;
1481 }
1482 b'*' if at_line_start => {
1483 at_line_start = false;
1484 i += 1;
1485 }
1486 _ => return false,
1487 }
1488 }
1489 true
1490}
1491
1492fn has_public_tag(comment_text: &str) -> bool {
1494 if bare_jsdoc_tag_end(comment_text, "@public").is_some() {
1495 return true;
1496 }
1497 for (i, _) in comment_text.match_indices("@api") {
1498 let after = i + "@api".len();
1499 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1500 let rest = comment_text[after..].trim_start();
1501 if rest.starts_with("public") {
1502 let after_public = "public".len();
1503 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1504 return true;
1505 }
1506 }
1507 }
1508 }
1509 false
1510}
1511
1512#[derive(Debug, Default, PartialEq, Eq)]
1513pub struct ImportBindingUsage {
1514 pub unused: Vec<String>,
1515 pub type_referenced: Vec<String>,
1516 pub value_referenced: Vec<String>,
1517}
1518
1519#[derive(Debug, Default, PartialEq, Eq)]
1528pub struct MockApiReferenceSpans {
1529 pub(crate) mock_bindings: rustc_hash::FxHashSet<Span>,
1530 pub(crate) vitest_namespaces: rustc_hash::FxHashSet<Span>,
1531}
1532
1533#[derive(Debug, Default, PartialEq, Eq)]
1534pub struct SemanticUsage {
1535 pub import_binding_usage: ImportBindingUsage,
1536 pub auto_import_candidates: Vec<String>,
1537 pub declaration_merges: Vec<fallow_types::extract::DeclarationMergeFact>,
1538 pub(crate) mock_api_reference_spans: MockApiReferenceSpans,
1539 pub(crate) module_binding_reference_spans: rustc_hash::FxHashSet<Span>,
1540 pub(crate) imported_call_reference_spans: rustc_hash::FxHashSet<Span>,
1541 pub(crate) unreferenced_import_equals_bindings: Vec<String>,
1546 pub(crate) component_contracts: fallow_types::extract::ComponentContractFacts,
1547}
1548
1549pub fn compute_semantic_usage_for_extractor(
1550 program: &Program<'_>,
1551 extractor: &mut ModuleInfoExtractor,
1552 template_used: &rustc_hash::FxHashSet<String>,
1553) -> SemanticUsage {
1554 let computed_enum_key_spans = extractor.computed_enum_key_reference_spans();
1555 let imported_call_candidates = extractor.imported_call_reference_candidates();
1556 let require_namespace_bindings = extractor.require_namespace_bindings();
1557 let mut semantic_usage = compute_semantic_usage_with_candidates(
1558 program,
1559 &extractor.imports,
1560 &extractor.exports,
1561 &require_namespace_bindings,
1562 template_used,
1563 SemanticReferenceCandidates {
1564 module_bindings: &computed_enum_key_spans,
1565 imported_calls: &imported_call_candidates,
1566 },
1567 );
1568 extractor.resolve_computed_enum_key_uses(&semantic_usage.module_binding_reference_spans);
1569 extractor.resolve_imported_call_sites(&semantic_usage.imported_call_reference_spans);
1570 report_unreferenced_import_equals_bindings(
1571 &mut semantic_usage,
1572 &extractor.exported_import_equals_names,
1573 );
1574 semantic_usage
1575}
1576
1577fn report_unreferenced_import_equals_bindings(
1592 semantic_usage: &mut SemanticUsage,
1593 exported_import_equals_names: &[String],
1594) {
1595 let unreferenced = std::mem::take(&mut semantic_usage.unreferenced_import_equals_bindings);
1596 if unreferenced.is_empty() {
1597 return;
1598 }
1599 let unused = &mut semantic_usage.import_binding_usage.unused;
1600 unused.extend(unreferenced.into_iter().filter(|name| {
1601 !exported_import_equals_names
1602 .iter()
1603 .any(|exported| exported == name)
1604 }));
1605 unused.sort_unstable();
1609 unused.dedup();
1610}
1611
1612#[derive(Clone, Copy)]
1613struct SemanticReferenceCandidates<'a> {
1614 module_bindings: &'a rustc_hash::FxHashSet<Span>,
1615 imported_calls: &'a rustc_hash::FxHashSet<Span>,
1616}
1617
1618fn compute_semantic_usage_with_candidates(
1619 program: &Program<'_>,
1620 imports: &[ImportInfo],
1621 exports: &[ExportInfo],
1622 require_namespace_bindings: &[String],
1623 template_used: &rustc_hash::FxHashSet<String>,
1624 candidates: SemanticReferenceCandidates<'_>,
1625) -> SemanticUsage {
1626 use oxc_semantic::SemanticBuilder;
1627 use rustc_hash::FxHashSet;
1628
1629 let semantic_ret = SemanticBuilder::new().with_build_nodes(true).build(program);
1630 let semantic = semantic_ret.semantic;
1631 let scoping = semantic.scoping();
1632 let root_scope = scoping.root_scope_id();
1633
1634 let mut unused = Vec::new();
1635 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1636 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1637 for import in imports {
1638 if import.local_name.is_empty() {
1639 continue;
1640 }
1641 if let Some((has_references, has_type_references, has_value_references)) =
1642 binding_reference_usage(scoping, &import.local_name)
1643 {
1644 if !has_references {
1645 if !template_used.contains(&import.local_name) {
1646 unused.push(import.local_name.clone());
1647 }
1648 continue;
1649 }
1650
1651 if has_type_references {
1652 type_referenced_bindings.insert(import.local_name.clone());
1653 }
1654 if has_value_references {
1655 value_referenced_bindings.insert(import.local_name.clone());
1656 }
1657 }
1658 }
1659
1660 let import_equals = classify_import_equals_bindings(
1661 scoping,
1662 require_namespace_bindings,
1663 template_used,
1664 &mut type_referenced_bindings,
1665 &mut value_referenced_bindings,
1666 );
1667
1668 unused.sort_unstable();
1669
1670 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1671 type_referenced_bindings.sort_unstable();
1672
1673 let mut value_referenced_bindings: Vec<String> =
1674 value_referenced_bindings.into_iter().collect();
1675 value_referenced_bindings.sort_unstable();
1676 let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1677 let declaration_merges = declaration_merge_facts(&semantic);
1678 let mut module_binding_reference_spans = FxHashSet::default();
1679 if !candidates.module_bindings.is_empty() {
1680 for symbol_id in scoping.symbol_ids() {
1681 if scoping.symbol_scope_id(symbol_id) != root_scope {
1682 continue;
1683 }
1684 module_binding_reference_spans.extend(
1685 scoping
1686 .get_resolved_references(symbol_id)
1687 .filter_map(|reference| {
1688 let AstKind::IdentifierReference(identifier) =
1689 semantic.nodes().kind(reference.node_id())
1690 else {
1691 return None;
1692 };
1693 candidates
1694 .module_bindings
1695 .contains(&identifier.span)
1696 .then_some(identifier.span)
1697 }),
1698 );
1699 }
1700 }
1701
1702 SemanticUsage {
1703 import_binding_usage: ImportBindingUsage {
1704 unused,
1705 type_referenced: type_referenced_bindings,
1706 value_referenced: value_referenced_bindings,
1707 },
1708 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1709 declaration_merges,
1710 mock_api_reference_spans,
1711 module_binding_reference_spans,
1712 imported_call_reference_spans: imported_call_reference_spans(
1713 &semantic,
1714 imports,
1715 candidates.imported_calls,
1716 ),
1717 unreferenced_import_equals_bindings: import_equals.unreferenced,
1718 component_contracts: crate::component_contracts::collect(&semantic, imports, exports),
1719 }
1720}
1721
1722fn imported_call_reference_spans(
1724 semantic: &oxc_semantic::Semantic<'_>,
1725 imports: &[ImportInfo],
1726 candidates: &rustc_hash::FxHashSet<Span>,
1727) -> rustc_hash::FxHashSet<Span> {
1728 let mut spans = rustc_hash::FxHashSet::default();
1729 if candidates.is_empty() {
1730 return spans;
1731 }
1732 let scoping = semantic.scoping();
1733 for import in imports {
1734 if import.is_type_only || import.local_name.is_empty() {
1735 continue;
1736 }
1737 let Some(symbol) = scoping.get_binding(
1738 scoping.root_scope_id(),
1739 oxc_str::Ident::from(import.local_name.as_str()),
1740 ) else {
1741 continue;
1742 };
1743 let mut declarations = scoping.symbol_declarations(symbol);
1744 let Some(declaration) = declarations.next() else {
1745 continue;
1746 };
1747 if declarations.next().is_some()
1748 || !matches!(
1749 semantic.nodes().kind(declaration),
1750 AstKind::ImportSpecifier(_)
1751 | AstKind::ImportDefaultSpecifier(_)
1752 | AstKind::ImportNamespaceSpecifier(_)
1753 )
1754 {
1755 continue;
1756 }
1757 spans.extend(
1758 scoping
1759 .get_resolved_references(symbol)
1760 .filter_map(|reference| {
1761 if !reference.is_value() {
1762 return None;
1763 }
1764 let AstKind::IdentifierReference(identifier) =
1765 semantic.nodes().kind(reference.node_id())
1766 else {
1767 return None;
1768 };
1769 candidates
1770 .contains(&identifier.span)
1771 .then_some(identifier.span)
1772 }),
1773 );
1774 }
1775 spans
1776}
1777
1778#[derive(Default)]
1780struct ImportEqualsClassification {
1781 unreferenced: Vec<String>,
1783}
1784
1785fn binding_reference_usage(
1790 scoping: &oxc_semantic::Scoping,
1791 local_name: &str,
1792) -> Option<(bool, bool, bool)> {
1793 let mut found_binding = false;
1794 let mut has_references = false;
1795 let mut has_type_references = false;
1796 let mut has_value_references = false;
1797 for symbol_id in scoping
1798 .symbol_ids()
1799 .filter(|symbol_id| scoping.symbol_name(*symbol_id) == local_name)
1800 {
1801 found_binding = true;
1802 for reference in scoping.get_resolved_references(symbol_id) {
1803 has_references = true;
1804 has_type_references |= reference.is_type();
1805 has_value_references |= reference.is_value();
1806 }
1807 }
1808 if found_binding
1809 && let Some(reference_ids) = scoping.root_unresolved_references().get(local_name)
1810 {
1811 for reference_id in reference_ids {
1812 let reference = scoping.get_reference(*reference_id);
1813 has_references = true;
1814 has_type_references |= reference.is_type();
1815 has_value_references |= reference.is_value();
1816 }
1817 }
1818 found_binding.then_some((has_references, has_type_references, has_value_references))
1819}
1820
1821fn classify_import_equals_bindings(
1837 scoping: &oxc_semantic::Scoping,
1838 import_equals_bindings: &[String],
1839 template_used: &rustc_hash::FxHashSet<String>,
1840 type_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1841 value_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1842) -> ImportEqualsClassification {
1843 if import_equals_bindings.is_empty() {
1844 return ImportEqualsClassification::default();
1845 }
1846
1847 let mut classification = ImportEqualsClassification::default();
1848 for local_name in import_equals_bindings {
1849 if local_name.is_empty() {
1850 continue;
1851 }
1852 let Some((has_references, has_type_references, has_value_references)) =
1853 binding_reference_usage(scoping, local_name)
1854 else {
1855 continue;
1856 };
1857 if !has_references {
1858 if !template_used.contains(local_name) {
1859 classification.unreferenced.push(local_name.clone());
1860 }
1861 continue;
1862 }
1863 if has_type_references {
1864 type_referenced_bindings.insert(local_name.clone());
1865 }
1866 if has_value_references {
1867 value_referenced_bindings.insert(local_name.clone());
1868 }
1869 }
1870 classification
1871}
1872
1873#[derive(Clone, Copy, PartialEq, Eq)]
1874enum MergeDeclarationKind {
1875 Interface,
1876 Class,
1877 Function,
1878 Enum,
1879 Namespace,
1880}
1881
1882fn declaration_merge_facts(
1883 semantic: &oxc_semantic::Semantic<'_>,
1884) -> Vec<fallow_types::extract::DeclarationMergeFact> {
1885 use fallow_types::extract::DeclarationMergeFact;
1886
1887 let scoping = semantic.scoping();
1888 let mut groups = Vec::new();
1889 for symbol_id in scoping.symbol_ids() {
1890 let declarations: Vec<_> = scoping
1891 .symbol_declarations(symbol_id)
1892 .filter_map(|node_id| merge_declaration(semantic.nodes().kind(node_id)))
1893 .collect();
1894 if declarations.len() < 2 {
1895 continue;
1896 }
1897 let mut selected = Vec::new();
1898 for (index, (kind, span)) in declarations.iter().enumerate() {
1899 if declarations
1903 .iter()
1904 .enumerate()
1905 .any(|(other_index, (other, _))| {
1906 other_index != index && compatible_merge(*kind, *other)
1907 })
1908 {
1909 selected.push((span.start, span.end));
1910 }
1911 }
1912 selected.sort_unstable();
1913 selected.dedup();
1914 if selected.len() > 1 {
1915 groups.push(DeclarationMergeFact {
1916 export_spans: selected,
1917 });
1918 }
1919 }
1920 groups.sort_unstable_by_key(|group| group.export_spans[0]);
1921 groups
1922}
1923
1924fn merge_declaration(kind: AstKind<'_>) -> Option<(MergeDeclarationKind, Span)> {
1925 match kind {
1926 AstKind::TSInterfaceDeclaration(declaration) => {
1927 Some((MergeDeclarationKind::Interface, declaration.id.span))
1928 }
1929 AstKind::Class(declaration) => declaration
1930 .id
1931 .as_ref()
1932 .map(|id| (MergeDeclarationKind::Class, id.span)),
1933 AstKind::Function(declaration) => declaration
1934 .id
1935 .as_ref()
1936 .map(|id| (MergeDeclarationKind::Function, id.span)),
1937 AstKind::TSEnumDeclaration(declaration) if !declaration.r#const => {
1938 Some((MergeDeclarationKind::Enum, declaration.id.span))
1939 }
1940 AstKind::TSNamespaceDeclaration(declaration) => {
1941 Some((MergeDeclarationKind::Namespace, declaration.id.span))
1942 }
1943 _ => None,
1944 }
1945}
1946
1947const fn compatible_merge(left: MergeDeclarationKind, right: MergeDeclarationKind) -> bool {
1948 use MergeDeclarationKind::{Class, Enum, Function, Interface, Namespace};
1949
1950 matches!(
1951 (left, right),
1952 (Interface, Interface | Class | Namespace)
1953 | (Class, Interface | Namespace)
1954 | (Function, Namespace)
1955 | (Enum, Enum | Namespace)
1956 | (Namespace, Interface | Class | Function | Enum | Namespace)
1957 )
1958}
1959
1960fn compute_mock_api_reference_spans(
1961 semantic: &oxc_semantic::Semantic<'_>,
1962 imports: &[ImportInfo],
1963 root_scope: oxc_semantic::ScopeId,
1964) -> MockApiReferenceSpans {
1965 let scoping = semantic.scoping();
1966 let mut spans = MockApiReferenceSpans::default();
1967
1968 let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
1969 let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
1970 else {
1971 return;
1972 };
1973 out.extend(
1974 scoping
1975 .get_resolved_references(symbol_id)
1976 .filter_map(|reference| {
1977 let AstKind::IdentifierReference(identifier) =
1978 semantic.nodes().kind(reference.node_id())
1979 else {
1980 return None;
1981 };
1982 Some(identifier.span)
1983 }),
1984 );
1985 };
1986
1987 for import in imports {
1988 if import.is_type_only || import.local_name.is_empty() {
1989 continue;
1990 }
1991 let is_vi_binding = import.source == "vitest"
1992 && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
1993 let is_jest_binding = import.source == "@jest/globals"
1994 && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
1995 let is_vitest_namespace =
1996 import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
1997
1998 if is_vi_binding || is_jest_binding {
1999 collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
2000 } else if is_vitest_namespace {
2001 collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
2002 }
2003 }
2004
2005 for (name, reference_ids) in scoping.root_unresolved_references() {
2013 if name.as_str() != "jest" {
2014 continue;
2015 }
2016 spans
2017 .mock_bindings
2018 .extend(reference_ids.iter().filter_map(|reference_id| {
2019 let reference = scoping.get_reference(*reference_id);
2020 if !reference.is_value() {
2021 return None;
2022 }
2023 let AstKind::IdentifierReference(identifier) =
2024 semantic.nodes().kind(reference.node_id())
2025 else {
2026 return None;
2027 };
2028 Some(identifier.span)
2029 }));
2030 }
2031
2032 spans
2033}
2034
2035fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
2036 use rustc_hash::FxHashSet;
2037
2038 let mut candidates: FxHashSet<String> = FxHashSet::default();
2039 for (name, reference_ids) in scoping.root_unresolved_references() {
2040 if reference_ids
2041 .iter()
2042 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
2043 {
2044 candidates.insert(name.as_str().to_string());
2045 }
2046 }
2047
2048 let mut candidates: Vec<String> = candidates.into_iter().collect();
2049 candidates.sort_unstable();
2050 candidates
2051}
2052
2053pub fn compute_import_binding_usage(
2076 program: &Program<'_>,
2077 imports: &[ImportInfo],
2078 import_equals_bindings: &[String],
2079 template_used: &rustc_hash::FxHashSet<String>,
2080) -> ImportBindingUsage {
2081 let mut semantic_usage = compute_semantic_usage_with_candidates(
2082 program,
2083 imports,
2084 &[],
2085 import_equals_bindings,
2086 template_used,
2087 SemanticReferenceCandidates {
2088 module_bindings: &rustc_hash::FxHashSet::default(),
2089 imported_calls: &rustc_hash::FxHashSet::default(),
2090 },
2091 );
2092 report_unreferenced_import_equals_bindings(&mut semantic_usage, &[]);
2096 semantic_usage.import_binding_usage
2097}
2098
2099#[cfg(test)]
2100mod tests {
2101 use super::{
2102 advance_jsdoc_brace_stack, classify_jsdoc_visibility_tag, parse_source_to_module,
2103 scan_jsdoc_import_tags_in, scan_jsdoc_imports_in,
2104 };
2105 use fallow_types::discover::FileId;
2106 use fallow_types::extract::{ImportInfo, ImportedName, VisibilityTag};
2107 use std::path::Path;
2108
2109 #[test]
2110 fn classify_jsdoc_visibility_tag_requires_a_bare_tag() {
2111 let cases = [
2112 (" * @public", Some(VisibilityTag::Public)),
2113 (" * @api public", Some(VisibilityTag::Public)),
2114 (" * @publicly", None),
2115 (" * @apipublic", None),
2116 (" * public", None),
2117 (" * @internal", Some(VisibilityTag::Internal)),
2118 (" * @internal-only", Some(VisibilityTag::Internal)),
2119 (" * @internalizer", None),
2120 (" * @internalFoo", None),
2121 (" * internal", None),
2122 (" * @beta", Some(VisibilityTag::Beta)),
2123 (" * @betaware", None),
2124 (" * @beta_x", None),
2125 (" * beta", None),
2126 ("@alpha", Some(VisibilityTag::Alpha)),
2127 ("@alpha Some description", Some(VisibilityTag::Alpha)),
2128 ("@alphabet", None),
2129 (" * alpha", None),
2130 (" * @expected-unused", Some(VisibilityTag::ExpectedUnused)),
2131 (" * @expected-unusedX", None),
2132 ];
2133 for (text, expected) in cases {
2134 let actual = classify_jsdoc_visibility_tag(text).map(|(tag, _)| tag);
2135 assert_eq!(actual, expected, "{text:?}");
2136 }
2137 }
2138
2139 #[test]
2140 fn classify_jsdoc_visibility_tag_keeps_the_expected_unused_reason() {
2141 assert_eq!(
2142 classify_jsdoc_visibility_tag(" * @expected-unused -- kept for the plugin API"),
2143 Some((
2144 VisibilityTag::ExpectedUnused,
2145 Some("kept for the plugin API".to_string())
2146 ))
2147 );
2148 assert_eq!(
2149 classify_jsdoc_visibility_tag(" * @expected-unused"),
2150 Some((VisibilityTag::ExpectedUnused, None))
2151 );
2152 }
2153
2154 fn scan(body: &str) -> Vec<ImportInfo> {
2155 let mut imports = Vec::new();
2156 scan_jsdoc_imports_in(body, &mut imports);
2157 imports
2158 }
2159
2160 #[test]
2161 fn scan_jsdoc_single_import_with_member() {
2162 let imports = scan(" * @param foo {import('./types').Foo}");
2163 assert_eq!(imports.len(), 1);
2164 assert_eq!(imports[0].source, "./types");
2165 assert_eq!(
2166 imports[0].imported_name,
2167 ImportedName::Named("Foo".to_string())
2168 );
2169 assert!(imports[0].is_type_only);
2170 assert!(imports[0].local_name.is_empty());
2171 }
2172
2173 #[test]
2174 fn script_auto_import_candidates_capture_zero_import_value_refs() {
2175 let info = parse_source_to_module(
2176 FileId(0),
2177 Path::new("pages/index.ts"),
2178 r"
2179 useCounter();
2180 const price = formatPrice(10);
2181 const localOnly = () => null;
2182 localOnly();
2183 type Local = UseTypeOnly;
2184 ",
2185 0,
2186 false,
2187 );
2188
2189 assert!(
2190 info.auto_import_candidates
2191 .contains(&"formatPrice".to_string())
2192 );
2193 assert!(
2194 info.auto_import_candidates
2195 .contains(&"useCounter".to_string())
2196 );
2197 assert!(
2198 !info
2199 .auto_import_candidates
2200 .contains(&"UseTypeOnly".to_string())
2201 );
2202 assert!(
2203 !info
2204 .auto_import_candidates
2205 .contains(&"localOnly".to_string())
2206 );
2207 }
2208
2209 #[test]
2210 fn script_auto_import_candidates_skip_explicit_imports() {
2211 let info = parse_source_to_module(
2212 FileId(0),
2213 Path::new("pages/index.ts"),
2214 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
2215 0,
2216 false,
2217 );
2218
2219 assert!(
2220 !info
2221 .auto_import_candidates
2222 .contains(&"useCounter".to_string())
2223 );
2224 assert!(
2225 info.auto_import_candidates
2226 .contains(&"useOther".to_string())
2227 );
2228 }
2229
2230 #[test]
2231 fn scan_jsdoc_double_quoted_path() {
2232 let imports = scan(r#" * @type {import("./types").Foo}"#);
2233 assert_eq!(imports.len(), 1);
2234 assert_eq!(imports[0].source, "./types");
2235 }
2236
2237 #[test]
2238 fn scan_jsdoc_multiple_imports_in_same_body() {
2239 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
2240 assert_eq!(imports.len(), 2);
2241 assert_eq!(imports[0].source, "./a");
2242 assert_eq!(imports[1].source, "./b");
2243 }
2244
2245 #[test]
2246 fn scan_jsdoc_union_annotation_captures_both_members() {
2247 let imports = scan(" * @type {import('./a').A | import('./b').B}");
2248 assert_eq!(imports.len(), 2);
2249 assert_eq!(
2250 imports[0].imported_name,
2251 ImportedName::Named("A".to_string())
2252 );
2253 assert_eq!(
2254 imports[1].imported_name,
2255 ImportedName::Named("B".to_string())
2256 );
2257 }
2258
2259 #[test]
2260 fn scan_jsdoc_nested_member_uses_first_segment() {
2261 let imports = scan(" * @type {import('./types').ns.Foo}");
2262 assert_eq!(imports.len(), 1);
2263 assert_eq!(
2264 imports[0].imported_name,
2265 ImportedName::Named("ns".to_string())
2266 );
2267 }
2268
2269 #[test]
2270 fn scan_jsdoc_parent_relative_path() {
2271 let imports = scan(" * @type {import('../lib/types.js').Foo}");
2272 assert_eq!(imports.len(), 1);
2273 assert_eq!(imports[0].source, "../lib/types.js");
2274 }
2275
2276 #[test]
2277 fn scan_jsdoc_bare_package_specifier() {
2278 let imports = scan(" * @type {import('@scope/pkg').Client}");
2279 assert_eq!(imports.len(), 1);
2280 assert_eq!(imports[0].source, "@scope/pkg");
2281 assert_eq!(
2282 imports[0].imported_name,
2283 ImportedName::Named("Client".to_string())
2284 );
2285 }
2286
2287 fn scan_tags(body: &str) -> Vec<(String, ImportedName)> {
2288 let mut imports = Vec::new();
2289 let mut namespaces = Vec::new();
2290 scan_jsdoc_import_tags_in(body, &mut imports, &mut namespaces);
2291 assert!(namespaces.is_empty());
2292 assert!(imports.iter().all(|import| import.is_type_only));
2293 assert!(imports.iter().all(|import| import.local_name.is_empty()));
2294 imports
2295 .into_iter()
2296 .map(|import| (import.source, import.imported_name))
2297 .collect()
2298 }
2299
2300 fn named(source: &str, name: &str) -> (String, ImportedName) {
2301 (source.to_string(), ImportedName::Named(name.to_string()))
2302 }
2303
2304 #[test]
2305 fn scan_jsdoc_import_tag_named_bindings() {
2306 assert_eq!(
2307 scan_tags(" @import { A, B as C, type D, 'e-f' as E } from '../types/foo' "),
2308 vec![
2309 named("../types/foo", "A"),
2310 named("../types/foo", "B"),
2311 named("../types/foo", "D"),
2312 named("../types/foo", "e-f"),
2313 ]
2314 );
2315 }
2316
2317 #[test]
2318 fn scan_jsdoc_import_tag_namespace_default_and_mixed() {
2319 assert_eq!(
2320 scan_tags(" @import Def from './def' "),
2321 vec![("./def".to_string(), ImportedName::Default)]
2322 );
2323 assert_eq!(
2324 scan_tags(" @import Def, { $A, default as B } from './mixed' "),
2325 vec![
2326 ("./mixed".to_string(), ImportedName::Default),
2327 named("./mixed", "$A"),
2328 ("./mixed".to_string(), ImportedName::Default),
2329 ]
2330 );
2331 assert_eq!(
2332 scan_tags(" @import {} from './empty' "),
2333 vec![("./empty".to_string(), ImportedName::SideEffect)]
2334 );
2335 }
2336
2337 fn jsdoc_tag_imports(source: &str) -> Vec<(String, ImportedName)> {
2338 let info = parse_source_to_module(FileId(0), Path::new("src/index.js"), source, 0, false);
2339 info.imports
2340 .into_iter()
2341 .filter(|import| import.is_type_only && import.local_name.is_empty())
2342 .map(|import| (import.source, import.imported_name))
2343 .collect()
2344 }
2345
2346 #[test]
2347 fn jsdoc_namespace_import_tag_credits_only_the_members_in_types() {
2348 let source = r#"/** @import * as ns from "./ns" */
2349/** @import * as unread from './unread' */
2350/**
2351 * Read ns.Prose in a sentence: no credit.
2352 * @param {ns.Shape | ns.Line} a
2353 * @returns {ns.Shape}
2354 */
2355export function draw(a) { return a; }
2356/** @type {Array<ns.Point>} */
2357export const points = [];
2358"#;
2359 assert_eq!(
2360 jsdoc_tag_imports(source),
2361 vec![
2362 ("./ns".to_string(), ImportedName::SideEffect),
2363 ("./unread".to_string(), ImportedName::SideEffect),
2364 named("./ns", "Shape"),
2365 named("./ns", "Line"),
2366 named("./ns", "Point"),
2367 ]
2368 );
2369 }
2370
2371 #[test]
2372 fn jsdoc_namespace_import_tag_keeps_default_binding() {
2373 assert_eq!(
2374 jsdoc_tag_imports(
2375 "/** @import Def, * as ns from './mod' */
2376/** @type {ns.A} */
2377export const a = 1;
2378"
2379 ),
2380 vec![
2381 ("./mod".to_string(), ImportedName::Default),
2382 ("./mod".to_string(), ImportedName::SideEffect),
2383 named("./mod", "A"),
2384 ]
2385 );
2386 }
2387
2388 #[test]
2389 fn scan_jsdoc_import_tag_spans_lines_and_tags() {
2390 let body = "\n * @import {\n * A,\n * B,\n * } from './multi'\n * @import { C } from './next'\n * @param {A} a\n ";
2391 assert_eq!(
2392 scan_tags(body),
2393 vec![
2394 named("./multi", "A"),
2395 named("./multi", "B"),
2396 named("./next", "C"),
2397 ]
2398 );
2399 }
2400
2401 #[test]
2402 fn scan_jsdoc_import_tag_ignores_non_tags_and_bad_clauses() {
2403 for body in [
2404 " Use @import { A } from './prose' in a sentence ",
2405 " @imports { A } from './plural' ",
2406 " @import { A } './missing-from' ",
2407 " @import { A } from '' ",
2408 " @import { A from './unclosed' ",
2409 " @import { A }\n * @param {A} from './next-tag'",
2410 " @import { A } from './truncated",
2411 " @import",
2412 " @import * from './no-alias' ",
2413 ] {
2414 assert!(scan_tags(body).is_empty(), "{body:?}");
2415 }
2416 }
2417
2418 #[test]
2419 fn scan_jsdoc_without_member_is_side_effect() {
2420 let imports = scan(" * @type {import('./types')}");
2421 assert_eq!(imports.len(), 1);
2422 assert_eq!(imports[0].source, "./types");
2423 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
2424 assert!(imports[0].is_type_only);
2425 }
2426
2427 #[test]
2428 fn scan_jsdoc_empty_path_is_skipped() {
2429 let imports = scan(" * @type {import('').Foo}");
2430 assert!(imports.is_empty());
2431 }
2432
2433 #[test]
2434 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
2435 let imports = scan(" * @type {import('./truncated");
2436 assert!(imports.is_empty());
2437 }
2438
2439 #[test]
2440 fn scan_jsdoc_missing_closing_paren_is_skipped() {
2441 let imports = scan(" * @type {import('./types'.Foo}");
2442 assert!(imports.is_empty());
2443 }
2444
2445 #[test]
2446 fn scan_jsdoc_whitespace_between_paren_and_dot() {
2447 let imports = scan(" * @type {import('./types') .Foo}");
2448 assert_eq!(imports.len(), 1);
2449 assert_eq!(imports[0].source, "./types");
2450 assert_eq!(
2451 imports[0].imported_name,
2452 ImportedName::Named("Foo".to_string())
2453 );
2454 }
2455
2456 #[test]
2457 fn scan_jsdoc_whitespace_between_paren_and_quote() {
2458 let imports = scan(" * @type {import( './types').Foo}");
2459 assert_eq!(imports.len(), 1);
2460 assert_eq!(imports[0].source, "./types");
2461 }
2462
2463 #[test]
2464 fn scan_jsdoc_non_quote_after_paren_skipped() {
2465 let imports = scan(" * @type {import(foo).Bar}");
2466 assert!(imports.is_empty());
2467 }
2468
2469 #[test]
2470 fn scan_jsdoc_ignores_prose_with_import_word() {
2471 let imports = scan(" * This is an important note about imports.");
2472 assert!(imports.is_empty());
2473 }
2474
2475 #[test]
2476 fn scan_jsdoc_utf8_path_works() {
2477 let imports = scan(" * @type {import('./héllo').Foo}");
2478 assert_eq!(imports.len(), 1);
2479 assert_eq!(imports[0].source, "./héllo");
2480 }
2481
2482 #[test]
2483 fn scan_jsdoc_empty_body_is_empty() {
2484 assert!(scan("").is_empty());
2485 }
2486
2487 #[test]
2488 fn scan_jsdoc_no_import_in_body_is_empty() {
2489 assert!(scan(" * @param foo The foo parameter").is_empty());
2490 }
2491
2492 #[test]
2498 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
2499 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
2502 let imports = scan(body);
2503 assert!(
2504 imports.is_empty(),
2505 "prose import() should not be matched; got: {:?}",
2506 imports
2507 .iter()
2508 .map(|i| i.source.as_str())
2509 .collect::<Vec<_>>()
2510 );
2511 }
2512
2513 #[test]
2514 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
2515 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
2516 let imports = scan(body);
2517 assert!(
2518 imports.is_empty(),
2519 "object-literal example import() should not be matched; got: {:?}",
2520 imports
2521 .iter()
2522 .map(|i| i.source.as_str())
2523 .collect::<Vec<_>>()
2524 );
2525 }
2526
2527 #[test]
2528 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
2529 let imports = scan(" * Use {import('./prose')} as an example string.");
2530 assert!(imports.is_empty());
2531 }
2532
2533 #[test]
2534 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
2535 let imports = scan("\n * @example\n * { import('./prose') }\n");
2536 assert!(imports.is_empty());
2537 }
2538
2539 #[test]
2543 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
2544 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
2545 let imports = scan(body);
2546 assert_eq!(imports.len(), 1, "got: {imports:?}");
2547 assert_eq!(imports[0].source, "./real");
2548 assert_eq!(
2549 imports[0].imported_name,
2550 ImportedName::Named("Foo".to_string())
2551 );
2552 }
2553
2554 #[test]
2555 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
2556 let body = "\n * @returns {\n * import('./real').Foo\n * }";
2557 let imports = scan(body);
2558 assert_eq!(imports.len(), 1, "got: {imports:?}");
2559 assert_eq!(imports[0].source, "./real");
2560 assert_eq!(
2561 imports[0].imported_name,
2562 ImportedName::Named("Foo".to_string())
2563 );
2564 }
2565
2566 #[test]
2567 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
2568 let body = "\n * @type\n * { import('./real').Foo }\n";
2569 let imports = scan(body);
2570 assert_eq!(imports.len(), 1, "got: {imports:?}");
2571 assert_eq!(imports[0].source, "./real");
2572 assert_eq!(
2573 imports[0].imported_name,
2574 ImportedName::Named("Foo".to_string())
2575 );
2576 }
2577
2578 #[test]
2579 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
2580 let imports = scan(" * @satisfies {import('./real').Foo}");
2581 assert_eq!(imports.len(), 1, "got: {imports:?}");
2582 assert_eq!(imports[0].source, "./real");
2583 assert_eq!(
2584 imports[0].imported_name,
2585 ImportedName::Named("Foo".to_string())
2586 );
2587 }
2588
2589 #[test]
2590 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
2591 let imports = scan(" * @template {import('./real').Foo} T");
2592 assert_eq!(imports.len(), 1, "got: {imports:?}");
2593 assert_eq!(imports[0].source, "./real");
2594 assert_eq!(
2595 imports[0].imported_name,
2596 ImportedName::Named("Foo".to_string())
2597 );
2598 }
2599
2600 #[test]
2601 fn scan_jsdoc_enum_type_tag_is_still_matched() {
2602 let imports = scan(" * @enum {import('./real').Foo}");
2603 assert_eq!(imports.len(), 1, "got: {imports:?}");
2604 assert_eq!(imports[0].source, "./real");
2605 assert_eq!(
2606 imports[0].imported_name,
2607 ImportedName::Named("Foo".to_string())
2608 );
2609 }
2610
2611 #[test]
2612 fn scan_jsdoc_appends_to_existing_imports() {
2613 let mut imports = vec![ImportInfo {
2614 source: "existing".to_string(),
2615 imported_name: ImportedName::Default,
2616 local_name: "existing".to_string(),
2617 is_type_only: false,
2618 is_type_only_star: false,
2619 from_style: false,
2620 span: oxc_span::Span::default(),
2621 source_span: oxc_span::Span::default(),
2622 }];
2623 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
2624 assert_eq!(imports.len(), 2);
2625 assert_eq!(imports[0].source, "existing");
2626 assert_eq!(imports[1].source, "./new");
2627 }
2628
2629 #[test]
2630 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
2631 let imports = scan(" * @type {import('./t').Abc}");
2632 assert_eq!(imports.len(), 1);
2633 assert_eq!(
2634 imports[0].imported_name,
2635 ImportedName::Named("Abc".to_string())
2636 );
2637 }
2638
2639 #[test]
2640 fn scan_jsdoc_empty_member_name_is_skipped() {
2641 let imports = scan(" * @type {import('./x').}");
2642 assert!(imports.is_empty());
2643 }
2644
2645 #[test]
2646 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
2647 use std::fmt::Write as _;
2653 let mut body = String::from("/**\n");
2654 for i in 0..200 {
2655 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
2656 }
2657 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
2660 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
2661 body.push_str(" */\n");
2662
2663 let imports = scan(&body);
2664 assert_eq!(imports.len(), 201, "got: {imports:?}");
2665 for (i, import) in imports.iter().take(200).enumerate() {
2666 assert_eq!(import.source, format!("./m{i}"));
2667 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
2668 assert!(import.is_type_only);
2669 assert!(import.local_name.is_empty());
2670 }
2671 assert_eq!(imports[200].source, "./deep");
2673 assert_eq!(
2674 imports[200].imported_name,
2675 ImportedName::Named("D".to_string())
2676 );
2677 }
2678
2679 #[test]
2680 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
2681 let cases = [
2685 " * @type {import('./a').A} and {plain} then {import('./b').B}",
2686 " * @remarks { import('./skip') } @param x {import('./c').C}",
2687 " * text } stray close { import('./d').D } trailing",
2688 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
2689 ];
2690 for body in cases {
2691 let bytes = body.as_bytes();
2692 let mut cursor = 0;
2693 while let Some(rel) = body[cursor..].find("import(") {
2694 let import_pos = cursor + rel;
2695 let mut fresh = Vec::new();
2697 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
2698 match b {
2699 b'{' => fresh.push(idx),
2700 b'}' => {
2701 fresh.pop();
2702 }
2703 _ => {}
2704 }
2705 }
2706 let mut stack = Vec::new();
2707 let mut scanned = 0;
2708 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
2709 assert_eq!(
2710 stack.last().copied(),
2711 fresh.last().copied(),
2712 "enclosing brace mismatch at {import_pos} in {body:?}"
2713 );
2714 cursor = import_pos + "import(".len();
2715 }
2716 }
2717 }
2718}