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) import_binding_references: Vec<fallow_types::extract::ImportBindingReference>,
1544 pub(crate) unreferenced_import_equals_bindings: Vec<String>,
1549 pub(crate) component_contracts: fallow_types::extract::ComponentContractFacts,
1550}
1551
1552pub fn compute_semantic_usage_for_extractor(
1553 program: &Program<'_>,
1554 extractor: &mut ModuleInfoExtractor,
1555 template_used: &rustc_hash::FxHashSet<String>,
1556) -> SemanticUsage {
1557 let computed_enum_key_spans = extractor.computed_enum_key_reference_spans();
1558 let imported_call_candidates = extractor.imported_call_reference_candidates();
1559 let require_namespace_bindings = extractor.require_namespace_bindings();
1560 let mut semantic_usage = compute_semantic_usage_with_candidates(
1561 program,
1562 &extractor.imports,
1563 &extractor.exports,
1564 &require_namespace_bindings,
1565 template_used,
1566 SemanticReferenceCandidates {
1567 module_bindings: &computed_enum_key_spans,
1568 imported_calls: &imported_call_candidates,
1569 },
1570 );
1571 extractor.resolve_computed_enum_key_uses(&semantic_usage.module_binding_reference_spans);
1572 extractor.resolve_imported_call_sites(&semantic_usage.imported_call_reference_spans);
1573 extractor.set_import_binding_references(std::mem::take(
1574 &mut semantic_usage.import_binding_references,
1575 ));
1576 report_unreferenced_import_equals_bindings(
1577 &mut semantic_usage,
1578 &extractor.exported_import_equals_names,
1579 );
1580 semantic_usage
1581}
1582
1583fn report_unreferenced_import_equals_bindings(
1598 semantic_usage: &mut SemanticUsage,
1599 exported_import_equals_names: &[String],
1600) {
1601 let unreferenced = std::mem::take(&mut semantic_usage.unreferenced_import_equals_bindings);
1602 if unreferenced.is_empty() {
1603 return;
1604 }
1605 let unused = &mut semantic_usage.import_binding_usage.unused;
1606 unused.extend(unreferenced.into_iter().filter(|name| {
1607 !exported_import_equals_names
1608 .iter()
1609 .any(|exported| exported == name)
1610 }));
1611 unused.sort_unstable();
1615 unused.dedup();
1616}
1617
1618#[derive(Clone, Copy)]
1619struct SemanticReferenceCandidates<'a> {
1620 module_bindings: &'a rustc_hash::FxHashSet<Span>,
1621 imported_calls: &'a rustc_hash::FxHashSet<Span>,
1622}
1623
1624fn compute_semantic_usage_with_candidates(
1625 program: &Program<'_>,
1626 imports: &[ImportInfo],
1627 exports: &[ExportInfo],
1628 require_namespace_bindings: &[String],
1629 template_used: &rustc_hash::FxHashSet<String>,
1630 candidates: SemanticReferenceCandidates<'_>,
1631) -> SemanticUsage {
1632 use oxc_semantic::SemanticBuilder;
1633 use rustc_hash::FxHashSet;
1634
1635 let semantic_ret = SemanticBuilder::new().with_build_nodes(true).build(program);
1636 let semantic = semantic_ret.semantic;
1637 let scoping = semantic.scoping();
1638 let root_scope = scoping.root_scope_id();
1639
1640 let mut unused = Vec::new();
1641 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1642 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1643 for import in imports {
1644 if import.local_name.is_empty() {
1645 continue;
1646 }
1647 if let Some((has_references, has_type_references, has_value_references)) =
1648 binding_reference_usage(scoping, &import.local_name)
1649 {
1650 if !has_references {
1651 if !template_used.contains(&import.local_name) {
1652 unused.push(import.local_name.clone());
1653 }
1654 continue;
1655 }
1656
1657 if has_type_references {
1658 type_referenced_bindings.insert(import.local_name.clone());
1659 }
1660 if has_value_references {
1661 value_referenced_bindings.insert(import.local_name.clone());
1662 }
1663 }
1664 }
1665
1666 let import_equals = classify_import_equals_bindings(
1667 scoping,
1668 require_namespace_bindings,
1669 template_used,
1670 &mut type_referenced_bindings,
1671 &mut value_referenced_bindings,
1672 );
1673
1674 unused.sort_unstable();
1675
1676 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1677 type_referenced_bindings.sort_unstable();
1678
1679 let mut value_referenced_bindings: Vec<String> =
1680 value_referenced_bindings.into_iter().collect();
1681 value_referenced_bindings.sort_unstable();
1682 let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1683 let declaration_merges = declaration_merge_facts(&semantic);
1684 let mut module_binding_reference_spans = FxHashSet::default();
1685 if !candidates.module_bindings.is_empty() {
1686 for symbol_id in scoping.symbol_ids() {
1687 if scoping.symbol_scope_id(symbol_id) != root_scope {
1688 continue;
1689 }
1690 module_binding_reference_spans.extend(
1691 scoping
1692 .get_resolved_references(symbol_id)
1693 .filter_map(|reference| {
1694 let AstKind::IdentifierReference(identifier) =
1695 semantic.nodes().kind(reference.node_id())
1696 else {
1697 return None;
1698 };
1699 candidates
1700 .module_bindings
1701 .contains(&identifier.span)
1702 .then_some(identifier.span)
1703 }),
1704 );
1705 }
1706 }
1707
1708 let imported_call_reference_spans =
1709 imported_call_reference_spans(&semantic, imports, candidates.imported_calls);
1710 let import_binding_references = crate::binding_references::collect(
1711 &semantic,
1712 imports,
1713 exports,
1714 &imported_call_reference_spans,
1715 );
1716 SemanticUsage {
1717 import_binding_usage: ImportBindingUsage {
1718 unused,
1719 type_referenced: type_referenced_bindings,
1720 value_referenced: value_referenced_bindings,
1721 },
1722 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1723 declaration_merges,
1724 mock_api_reference_spans,
1725 module_binding_reference_spans,
1726 imported_call_reference_spans,
1727 import_binding_references,
1728 unreferenced_import_equals_bindings: import_equals.unreferenced,
1729 component_contracts: crate::component_contracts::collect(&semantic, imports, exports),
1730 }
1731}
1732
1733fn imported_call_reference_spans(
1735 semantic: &oxc_semantic::Semantic<'_>,
1736 imports: &[ImportInfo],
1737 candidates: &rustc_hash::FxHashSet<Span>,
1738) -> rustc_hash::FxHashSet<Span> {
1739 let mut spans = rustc_hash::FxHashSet::default();
1740 if candidates.is_empty() {
1741 return spans;
1742 }
1743 let scoping = semantic.scoping();
1744 for import in imports {
1745 let Some(symbol) = crate::binding_references::admitted_import_symbol(semantic, import)
1746 else {
1747 continue;
1748 };
1749 spans.extend(
1750 scoping
1751 .get_resolved_references(symbol)
1752 .filter_map(|reference| {
1753 if !reference.is_value() {
1754 return None;
1755 }
1756 let AstKind::IdentifierReference(identifier) =
1757 semantic.nodes().kind(reference.node_id())
1758 else {
1759 return None;
1760 };
1761 candidates
1762 .contains(&identifier.span)
1763 .then_some(identifier.span)
1764 }),
1765 );
1766 }
1767 spans
1768}
1769
1770#[derive(Default)]
1772struct ImportEqualsClassification {
1773 unreferenced: Vec<String>,
1775}
1776
1777fn binding_reference_usage(
1782 scoping: &oxc_semantic::Scoping,
1783 local_name: &str,
1784) -> Option<(bool, bool, bool)> {
1785 let mut found_binding = false;
1786 let mut has_references = false;
1787 let mut has_type_references = false;
1788 let mut has_value_references = false;
1789 for symbol_id in scoping
1790 .symbol_ids()
1791 .filter(|symbol_id| scoping.symbol_name(*symbol_id) == local_name)
1792 {
1793 found_binding = true;
1794 for reference in scoping.get_resolved_references(symbol_id) {
1795 has_references = true;
1796 has_type_references |= reference.is_type();
1797 has_value_references |= reference.is_value();
1798 }
1799 }
1800 if found_binding
1801 && let Some(reference_ids) = scoping.root_unresolved_references().get(local_name)
1802 {
1803 for reference_id in reference_ids {
1804 let reference = scoping.get_reference(*reference_id);
1805 has_references = true;
1806 has_type_references |= reference.is_type();
1807 has_value_references |= reference.is_value();
1808 }
1809 }
1810 found_binding.then_some((has_references, has_type_references, has_value_references))
1811}
1812
1813fn classify_import_equals_bindings(
1829 scoping: &oxc_semantic::Scoping,
1830 import_equals_bindings: &[String],
1831 template_used: &rustc_hash::FxHashSet<String>,
1832 type_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1833 value_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1834) -> ImportEqualsClassification {
1835 if import_equals_bindings.is_empty() {
1836 return ImportEqualsClassification::default();
1837 }
1838
1839 let mut classification = ImportEqualsClassification::default();
1840 for local_name in import_equals_bindings {
1841 if local_name.is_empty() {
1842 continue;
1843 }
1844 let Some((has_references, has_type_references, has_value_references)) =
1845 binding_reference_usage(scoping, local_name)
1846 else {
1847 continue;
1848 };
1849 if !has_references {
1850 if !template_used.contains(local_name) {
1851 classification.unreferenced.push(local_name.clone());
1852 }
1853 continue;
1854 }
1855 if has_type_references {
1856 type_referenced_bindings.insert(local_name.clone());
1857 }
1858 if has_value_references {
1859 value_referenced_bindings.insert(local_name.clone());
1860 }
1861 }
1862 classification
1863}
1864
1865#[derive(Clone, Copy, PartialEq, Eq)]
1866enum MergeDeclarationKind {
1867 Interface,
1868 Class,
1869 Function,
1870 Enum,
1871 Namespace,
1872}
1873
1874fn declaration_merge_facts(
1875 semantic: &oxc_semantic::Semantic<'_>,
1876) -> Vec<fallow_types::extract::DeclarationMergeFact> {
1877 use fallow_types::extract::DeclarationMergeFact;
1878
1879 let scoping = semantic.scoping();
1880 let mut groups = Vec::new();
1881 for symbol_id in scoping.symbol_ids() {
1882 let declarations: Vec<_> = scoping
1883 .symbol_declarations(symbol_id)
1884 .filter_map(|node_id| merge_declaration(semantic.nodes().kind(node_id)))
1885 .collect();
1886 if declarations.len() < 2 {
1887 continue;
1888 }
1889 let mut selected = Vec::new();
1890 for (index, (kind, span)) in declarations.iter().enumerate() {
1891 if declarations
1895 .iter()
1896 .enumerate()
1897 .any(|(other_index, (other, _))| {
1898 other_index != index && compatible_merge(*kind, *other)
1899 })
1900 {
1901 selected.push((span.start, span.end));
1902 }
1903 }
1904 selected.sort_unstable();
1905 selected.dedup();
1906 if selected.len() > 1 {
1907 groups.push(DeclarationMergeFact {
1908 export_spans: selected,
1909 });
1910 }
1911 }
1912 groups.sort_unstable_by_key(|group| group.export_spans[0]);
1913 groups
1914}
1915
1916fn merge_declaration(kind: AstKind<'_>) -> Option<(MergeDeclarationKind, Span)> {
1917 match kind {
1918 AstKind::TSInterfaceDeclaration(declaration) => {
1919 Some((MergeDeclarationKind::Interface, declaration.id.span))
1920 }
1921 AstKind::Class(declaration) => declaration
1922 .id
1923 .as_ref()
1924 .map(|id| (MergeDeclarationKind::Class, id.span)),
1925 AstKind::Function(declaration) => declaration
1926 .id
1927 .as_ref()
1928 .map(|id| (MergeDeclarationKind::Function, id.span)),
1929 AstKind::TSEnumDeclaration(declaration) if !declaration.r#const => {
1930 Some((MergeDeclarationKind::Enum, declaration.id.span))
1931 }
1932 AstKind::TSNamespaceDeclaration(declaration) => {
1933 Some((MergeDeclarationKind::Namespace, declaration.id.span))
1934 }
1935 _ => None,
1936 }
1937}
1938
1939const fn compatible_merge(left: MergeDeclarationKind, right: MergeDeclarationKind) -> bool {
1940 use MergeDeclarationKind::{Class, Enum, Function, Interface, Namespace};
1941
1942 matches!(
1943 (left, right),
1944 (Interface, Interface | Class | Namespace)
1945 | (Class, Interface | Namespace)
1946 | (Function, Namespace)
1947 | (Enum, Enum | Namespace)
1948 | (Namespace, Interface | Class | Function | Enum | Namespace)
1949 )
1950}
1951
1952fn compute_mock_api_reference_spans(
1953 semantic: &oxc_semantic::Semantic<'_>,
1954 imports: &[ImportInfo],
1955 root_scope: oxc_semantic::ScopeId,
1956) -> MockApiReferenceSpans {
1957 let scoping = semantic.scoping();
1958 let mut spans = MockApiReferenceSpans::default();
1959
1960 let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
1961 let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
1962 else {
1963 return;
1964 };
1965 out.extend(
1966 scoping
1967 .get_resolved_references(symbol_id)
1968 .filter_map(|reference| {
1969 let AstKind::IdentifierReference(identifier) =
1970 semantic.nodes().kind(reference.node_id())
1971 else {
1972 return None;
1973 };
1974 Some(identifier.span)
1975 }),
1976 );
1977 };
1978
1979 for import in imports {
1980 if import.is_type_only || import.local_name.is_empty() {
1981 continue;
1982 }
1983 let is_vi_binding = import.source == "vitest"
1984 && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
1985 let is_jest_binding = import.source == "@jest/globals"
1986 && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
1987 let is_vitest_namespace =
1988 import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
1989
1990 if is_vi_binding || is_jest_binding {
1991 collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
1992 } else if is_vitest_namespace {
1993 collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
1994 }
1995 }
1996
1997 for (name, reference_ids) in scoping.root_unresolved_references() {
2005 if name.as_str() != "jest" {
2006 continue;
2007 }
2008 spans
2009 .mock_bindings
2010 .extend(reference_ids.iter().filter_map(|reference_id| {
2011 let reference = scoping.get_reference(*reference_id);
2012 if !reference.is_value() {
2013 return None;
2014 }
2015 let AstKind::IdentifierReference(identifier) =
2016 semantic.nodes().kind(reference.node_id())
2017 else {
2018 return None;
2019 };
2020 Some(identifier.span)
2021 }));
2022 }
2023
2024 spans
2025}
2026
2027fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
2028 use rustc_hash::FxHashSet;
2029
2030 let mut candidates: FxHashSet<String> = FxHashSet::default();
2031 for (name, reference_ids) in scoping.root_unresolved_references() {
2032 if reference_ids
2033 .iter()
2034 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
2035 {
2036 candidates.insert(name.as_str().to_string());
2037 }
2038 }
2039
2040 let mut candidates: Vec<String> = candidates.into_iter().collect();
2041 candidates.sort_unstable();
2042 candidates
2043}
2044
2045pub fn compute_import_binding_usage(
2068 program: &Program<'_>,
2069 imports: &[ImportInfo],
2070 import_equals_bindings: &[String],
2071 template_used: &rustc_hash::FxHashSet<String>,
2072) -> ImportBindingUsage {
2073 let mut semantic_usage = compute_semantic_usage_with_candidates(
2074 program,
2075 imports,
2076 &[],
2077 import_equals_bindings,
2078 template_used,
2079 SemanticReferenceCandidates {
2080 module_bindings: &rustc_hash::FxHashSet::default(),
2081 imported_calls: &rustc_hash::FxHashSet::default(),
2082 },
2083 );
2084 report_unreferenced_import_equals_bindings(&mut semantic_usage, &[]);
2088 semantic_usage.import_binding_usage
2089}
2090
2091#[cfg(test)]
2092mod tests {
2093 use super::{
2094 advance_jsdoc_brace_stack, classify_jsdoc_visibility_tag, parse_source_to_module,
2095 scan_jsdoc_import_tags_in, scan_jsdoc_imports_in,
2096 };
2097 use fallow_types::discover::FileId;
2098 use fallow_types::extract::{ImportInfo, ImportedName, VisibilityTag};
2099 use std::path::Path;
2100
2101 #[test]
2102 fn classify_jsdoc_visibility_tag_requires_a_bare_tag() {
2103 let cases = [
2104 (" * @public", Some(VisibilityTag::Public)),
2105 (" * @api public", Some(VisibilityTag::Public)),
2106 (" * @publicly", None),
2107 (" * @apipublic", None),
2108 (" * public", None),
2109 (" * @internal", Some(VisibilityTag::Internal)),
2110 (" * @internal-only", Some(VisibilityTag::Internal)),
2111 (" * @internalizer", None),
2112 (" * @internalFoo", None),
2113 (" * internal", None),
2114 (" * @beta", Some(VisibilityTag::Beta)),
2115 (" * @betaware", None),
2116 (" * @beta_x", None),
2117 (" * beta", None),
2118 ("@alpha", Some(VisibilityTag::Alpha)),
2119 ("@alpha Some description", Some(VisibilityTag::Alpha)),
2120 ("@alphabet", None),
2121 (" * alpha", None),
2122 (" * @expected-unused", Some(VisibilityTag::ExpectedUnused)),
2123 (" * @expected-unusedX", None),
2124 ];
2125 for (text, expected) in cases {
2126 let actual = classify_jsdoc_visibility_tag(text).map(|(tag, _)| tag);
2127 assert_eq!(actual, expected, "{text:?}");
2128 }
2129 }
2130
2131 #[test]
2132 fn classify_jsdoc_visibility_tag_keeps_the_expected_unused_reason() {
2133 assert_eq!(
2134 classify_jsdoc_visibility_tag(" * @expected-unused -- kept for the plugin API"),
2135 Some((
2136 VisibilityTag::ExpectedUnused,
2137 Some("kept for the plugin API".to_string())
2138 ))
2139 );
2140 assert_eq!(
2141 classify_jsdoc_visibility_tag(" * @expected-unused"),
2142 Some((VisibilityTag::ExpectedUnused, None))
2143 );
2144 }
2145
2146 fn scan(body: &str) -> Vec<ImportInfo> {
2147 let mut imports = Vec::new();
2148 scan_jsdoc_imports_in(body, &mut imports);
2149 imports
2150 }
2151
2152 #[test]
2153 fn scan_jsdoc_single_import_with_member() {
2154 let imports = scan(" * @param foo {import('./types').Foo}");
2155 assert_eq!(imports.len(), 1);
2156 assert_eq!(imports[0].source, "./types");
2157 assert_eq!(
2158 imports[0].imported_name,
2159 ImportedName::Named("Foo".to_string())
2160 );
2161 assert!(imports[0].is_type_only);
2162 assert!(imports[0].local_name.is_empty());
2163 }
2164
2165 #[test]
2166 fn script_auto_import_candidates_capture_zero_import_value_refs() {
2167 let info = parse_source_to_module(
2168 FileId(0),
2169 Path::new("pages/index.ts"),
2170 r"
2171 useCounter();
2172 const price = formatPrice(10);
2173 const localOnly = () => null;
2174 localOnly();
2175 type Local = UseTypeOnly;
2176 ",
2177 0,
2178 false,
2179 );
2180
2181 assert!(
2182 info.auto_import_candidates
2183 .contains(&"formatPrice".to_string())
2184 );
2185 assert!(
2186 info.auto_import_candidates
2187 .contains(&"useCounter".to_string())
2188 );
2189 assert!(
2190 !info
2191 .auto_import_candidates
2192 .contains(&"UseTypeOnly".to_string())
2193 );
2194 assert!(
2195 !info
2196 .auto_import_candidates
2197 .contains(&"localOnly".to_string())
2198 );
2199 }
2200
2201 #[test]
2202 fn script_auto_import_candidates_skip_explicit_imports() {
2203 let info = parse_source_to_module(
2204 FileId(0),
2205 Path::new("pages/index.ts"),
2206 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
2207 0,
2208 false,
2209 );
2210
2211 assert!(
2212 !info
2213 .auto_import_candidates
2214 .contains(&"useCounter".to_string())
2215 );
2216 assert!(
2217 info.auto_import_candidates
2218 .contains(&"useOther".to_string())
2219 );
2220 }
2221
2222 #[test]
2223 fn scan_jsdoc_double_quoted_path() {
2224 let imports = scan(r#" * @type {import("./types").Foo}"#);
2225 assert_eq!(imports.len(), 1);
2226 assert_eq!(imports[0].source, "./types");
2227 }
2228
2229 #[test]
2230 fn scan_jsdoc_multiple_imports_in_same_body() {
2231 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
2232 assert_eq!(imports.len(), 2);
2233 assert_eq!(imports[0].source, "./a");
2234 assert_eq!(imports[1].source, "./b");
2235 }
2236
2237 #[test]
2238 fn scan_jsdoc_union_annotation_captures_both_members() {
2239 let imports = scan(" * @type {import('./a').A | import('./b').B}");
2240 assert_eq!(imports.len(), 2);
2241 assert_eq!(
2242 imports[0].imported_name,
2243 ImportedName::Named("A".to_string())
2244 );
2245 assert_eq!(
2246 imports[1].imported_name,
2247 ImportedName::Named("B".to_string())
2248 );
2249 }
2250
2251 #[test]
2252 fn scan_jsdoc_nested_member_uses_first_segment() {
2253 let imports = scan(" * @type {import('./types').ns.Foo}");
2254 assert_eq!(imports.len(), 1);
2255 assert_eq!(
2256 imports[0].imported_name,
2257 ImportedName::Named("ns".to_string())
2258 );
2259 }
2260
2261 #[test]
2262 fn scan_jsdoc_parent_relative_path() {
2263 let imports = scan(" * @type {import('../lib/types.js').Foo}");
2264 assert_eq!(imports.len(), 1);
2265 assert_eq!(imports[0].source, "../lib/types.js");
2266 }
2267
2268 #[test]
2269 fn scan_jsdoc_bare_package_specifier() {
2270 let imports = scan(" * @type {import('@scope/pkg').Client}");
2271 assert_eq!(imports.len(), 1);
2272 assert_eq!(imports[0].source, "@scope/pkg");
2273 assert_eq!(
2274 imports[0].imported_name,
2275 ImportedName::Named("Client".to_string())
2276 );
2277 }
2278
2279 fn scan_tags(body: &str) -> Vec<(String, ImportedName)> {
2280 let mut imports = Vec::new();
2281 let mut namespaces = Vec::new();
2282 scan_jsdoc_import_tags_in(body, &mut imports, &mut namespaces);
2283 assert!(namespaces.is_empty());
2284 assert!(imports.iter().all(|import| import.is_type_only));
2285 assert!(imports.iter().all(|import| import.local_name.is_empty()));
2286 imports
2287 .into_iter()
2288 .map(|import| (import.source, import.imported_name))
2289 .collect()
2290 }
2291
2292 fn named(source: &str, name: &str) -> (String, ImportedName) {
2293 (source.to_string(), ImportedName::Named(name.to_string()))
2294 }
2295
2296 #[test]
2297 fn scan_jsdoc_import_tag_named_bindings() {
2298 assert_eq!(
2299 scan_tags(" @import { A, B as C, type D, 'e-f' as E } from '../types/foo' "),
2300 vec![
2301 named("../types/foo", "A"),
2302 named("../types/foo", "B"),
2303 named("../types/foo", "D"),
2304 named("../types/foo", "e-f"),
2305 ]
2306 );
2307 }
2308
2309 #[test]
2310 fn scan_jsdoc_import_tag_namespace_default_and_mixed() {
2311 assert_eq!(
2312 scan_tags(" @import Def from './def' "),
2313 vec![("./def".to_string(), ImportedName::Default)]
2314 );
2315 assert_eq!(
2316 scan_tags(" @import Def, { $A, default as B } from './mixed' "),
2317 vec![
2318 ("./mixed".to_string(), ImportedName::Default),
2319 named("./mixed", "$A"),
2320 ("./mixed".to_string(), ImportedName::Default),
2321 ]
2322 );
2323 assert_eq!(
2324 scan_tags(" @import {} from './empty' "),
2325 vec![("./empty".to_string(), ImportedName::SideEffect)]
2326 );
2327 }
2328
2329 fn jsdoc_tag_imports(source: &str) -> Vec<(String, ImportedName)> {
2330 let info = parse_source_to_module(FileId(0), Path::new("src/index.js"), source, 0, false);
2331 info.imports
2332 .into_iter()
2333 .filter(|import| import.is_type_only && import.local_name.is_empty())
2334 .map(|import| (import.source, import.imported_name))
2335 .collect()
2336 }
2337
2338 #[test]
2339 fn jsdoc_namespace_import_tag_credits_only_the_members_in_types() {
2340 let source = r#"/** @import * as ns from "./ns" */
2341/** @import * as unread from './unread' */
2342/**
2343 * Read ns.Prose in a sentence: no credit.
2344 * @param {ns.Shape | ns.Line} a
2345 * @returns {ns.Shape}
2346 */
2347export function draw(a) { return a; }
2348/** @type {Array<ns.Point>} */
2349export const points = [];
2350"#;
2351 assert_eq!(
2352 jsdoc_tag_imports(source),
2353 vec![
2354 ("./ns".to_string(), ImportedName::SideEffect),
2355 ("./unread".to_string(), ImportedName::SideEffect),
2356 named("./ns", "Shape"),
2357 named("./ns", "Line"),
2358 named("./ns", "Point"),
2359 ]
2360 );
2361 }
2362
2363 #[test]
2364 fn jsdoc_namespace_import_tag_keeps_default_binding() {
2365 assert_eq!(
2366 jsdoc_tag_imports(
2367 "/** @import Def, * as ns from './mod' */
2368/** @type {ns.A} */
2369export const a = 1;
2370"
2371 ),
2372 vec![
2373 ("./mod".to_string(), ImportedName::Default),
2374 ("./mod".to_string(), ImportedName::SideEffect),
2375 named("./mod", "A"),
2376 ]
2377 );
2378 }
2379
2380 #[test]
2381 fn scan_jsdoc_import_tag_spans_lines_and_tags() {
2382 let body = "\n * @import {\n * A,\n * B,\n * } from './multi'\n * @import { C } from './next'\n * @param {A} a\n ";
2383 assert_eq!(
2384 scan_tags(body),
2385 vec![
2386 named("./multi", "A"),
2387 named("./multi", "B"),
2388 named("./next", "C"),
2389 ]
2390 );
2391 }
2392
2393 #[test]
2394 fn scan_jsdoc_import_tag_ignores_non_tags_and_bad_clauses() {
2395 for body in [
2396 " Use @import { A } from './prose' in a sentence ",
2397 " @imports { A } from './plural' ",
2398 " @import { A } './missing-from' ",
2399 " @import { A } from '' ",
2400 " @import { A from './unclosed' ",
2401 " @import { A }\n * @param {A} from './next-tag'",
2402 " @import { A } from './truncated",
2403 " @import",
2404 " @import * from './no-alias' ",
2405 ] {
2406 assert!(scan_tags(body).is_empty(), "{body:?}");
2407 }
2408 }
2409
2410 #[test]
2411 fn scan_jsdoc_without_member_is_side_effect() {
2412 let imports = scan(" * @type {import('./types')}");
2413 assert_eq!(imports.len(), 1);
2414 assert_eq!(imports[0].source, "./types");
2415 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
2416 assert!(imports[0].is_type_only);
2417 }
2418
2419 #[test]
2420 fn scan_jsdoc_empty_path_is_skipped() {
2421 let imports = scan(" * @type {import('').Foo}");
2422 assert!(imports.is_empty());
2423 }
2424
2425 #[test]
2426 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
2427 let imports = scan(" * @type {import('./truncated");
2428 assert!(imports.is_empty());
2429 }
2430
2431 #[test]
2432 fn scan_jsdoc_missing_closing_paren_is_skipped() {
2433 let imports = scan(" * @type {import('./types'.Foo}");
2434 assert!(imports.is_empty());
2435 }
2436
2437 #[test]
2438 fn scan_jsdoc_whitespace_between_paren_and_dot() {
2439 let imports = scan(" * @type {import('./types') .Foo}");
2440 assert_eq!(imports.len(), 1);
2441 assert_eq!(imports[0].source, "./types");
2442 assert_eq!(
2443 imports[0].imported_name,
2444 ImportedName::Named("Foo".to_string())
2445 );
2446 }
2447
2448 #[test]
2449 fn scan_jsdoc_whitespace_between_paren_and_quote() {
2450 let imports = scan(" * @type {import( './types').Foo}");
2451 assert_eq!(imports.len(), 1);
2452 assert_eq!(imports[0].source, "./types");
2453 }
2454
2455 #[test]
2456 fn scan_jsdoc_non_quote_after_paren_skipped() {
2457 let imports = scan(" * @type {import(foo).Bar}");
2458 assert!(imports.is_empty());
2459 }
2460
2461 #[test]
2462 fn scan_jsdoc_ignores_prose_with_import_word() {
2463 let imports = scan(" * This is an important note about imports.");
2464 assert!(imports.is_empty());
2465 }
2466
2467 #[test]
2468 fn scan_jsdoc_utf8_path_works() {
2469 let imports = scan(" * @type {import('./héllo').Foo}");
2470 assert_eq!(imports.len(), 1);
2471 assert_eq!(imports[0].source, "./héllo");
2472 }
2473
2474 #[test]
2475 fn scan_jsdoc_empty_body_is_empty() {
2476 assert!(scan("").is_empty());
2477 }
2478
2479 #[test]
2480 fn scan_jsdoc_no_import_in_body_is_empty() {
2481 assert!(scan(" * @param foo The foo parameter").is_empty());
2482 }
2483
2484 #[test]
2490 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
2491 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
2494 let imports = scan(body);
2495 assert!(
2496 imports.is_empty(),
2497 "prose import() should not be matched; got: {:?}",
2498 imports
2499 .iter()
2500 .map(|i| i.source.as_str())
2501 .collect::<Vec<_>>()
2502 );
2503 }
2504
2505 #[test]
2506 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
2507 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
2508 let imports = scan(body);
2509 assert!(
2510 imports.is_empty(),
2511 "object-literal example import() should not be matched; got: {:?}",
2512 imports
2513 .iter()
2514 .map(|i| i.source.as_str())
2515 .collect::<Vec<_>>()
2516 );
2517 }
2518
2519 #[test]
2520 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
2521 let imports = scan(" * Use {import('./prose')} as an example string.");
2522 assert!(imports.is_empty());
2523 }
2524
2525 #[test]
2526 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
2527 let imports = scan("\n * @example\n * { import('./prose') }\n");
2528 assert!(imports.is_empty());
2529 }
2530
2531 #[test]
2535 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
2536 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
2537 let imports = scan(body);
2538 assert_eq!(imports.len(), 1, "got: {imports:?}");
2539 assert_eq!(imports[0].source, "./real");
2540 assert_eq!(
2541 imports[0].imported_name,
2542 ImportedName::Named("Foo".to_string())
2543 );
2544 }
2545
2546 #[test]
2547 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
2548 let body = "\n * @returns {\n * import('./real').Foo\n * }";
2549 let imports = scan(body);
2550 assert_eq!(imports.len(), 1, "got: {imports:?}");
2551 assert_eq!(imports[0].source, "./real");
2552 assert_eq!(
2553 imports[0].imported_name,
2554 ImportedName::Named("Foo".to_string())
2555 );
2556 }
2557
2558 #[test]
2559 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
2560 let body = "\n * @type\n * { import('./real').Foo }\n";
2561 let imports = scan(body);
2562 assert_eq!(imports.len(), 1, "got: {imports:?}");
2563 assert_eq!(imports[0].source, "./real");
2564 assert_eq!(
2565 imports[0].imported_name,
2566 ImportedName::Named("Foo".to_string())
2567 );
2568 }
2569
2570 #[test]
2571 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
2572 let imports = scan(" * @satisfies {import('./real').Foo}");
2573 assert_eq!(imports.len(), 1, "got: {imports:?}");
2574 assert_eq!(imports[0].source, "./real");
2575 assert_eq!(
2576 imports[0].imported_name,
2577 ImportedName::Named("Foo".to_string())
2578 );
2579 }
2580
2581 #[test]
2582 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
2583 let imports = scan(" * @template {import('./real').Foo} T");
2584 assert_eq!(imports.len(), 1, "got: {imports:?}");
2585 assert_eq!(imports[0].source, "./real");
2586 assert_eq!(
2587 imports[0].imported_name,
2588 ImportedName::Named("Foo".to_string())
2589 );
2590 }
2591
2592 #[test]
2593 fn scan_jsdoc_enum_type_tag_is_still_matched() {
2594 let imports = scan(" * @enum {import('./real').Foo}");
2595 assert_eq!(imports.len(), 1, "got: {imports:?}");
2596 assert_eq!(imports[0].source, "./real");
2597 assert_eq!(
2598 imports[0].imported_name,
2599 ImportedName::Named("Foo".to_string())
2600 );
2601 }
2602
2603 #[test]
2604 fn scan_jsdoc_appends_to_existing_imports() {
2605 let mut imports = vec![ImportInfo {
2606 source: "existing".to_string(),
2607 imported_name: ImportedName::Default,
2608 local_name: "existing".to_string(),
2609 is_type_only: false,
2610 is_type_only_star: false,
2611 from_style: false,
2612 span: oxc_span::Span::default(),
2613 source_span: oxc_span::Span::default(),
2614 }];
2615 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
2616 assert_eq!(imports.len(), 2);
2617 assert_eq!(imports[0].source, "existing");
2618 assert_eq!(imports[1].source, "./new");
2619 }
2620
2621 #[test]
2622 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
2623 let imports = scan(" * @type {import('./t').Abc}");
2624 assert_eq!(imports.len(), 1);
2625 assert_eq!(
2626 imports[0].imported_name,
2627 ImportedName::Named("Abc".to_string())
2628 );
2629 }
2630
2631 #[test]
2632 fn scan_jsdoc_empty_member_name_is_skipped() {
2633 let imports = scan(" * @type {import('./x').}");
2634 assert!(imports.is_empty());
2635 }
2636
2637 #[test]
2638 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
2639 use std::fmt::Write as _;
2645 let mut body = String::from("/**\n");
2646 for i in 0..200 {
2647 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
2648 }
2649 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
2652 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
2653 body.push_str(" */\n");
2654
2655 let imports = scan(&body);
2656 assert_eq!(imports.len(), 201, "got: {imports:?}");
2657 for (i, import) in imports.iter().take(200).enumerate() {
2658 assert_eq!(import.source, format!("./m{i}"));
2659 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
2660 assert!(import.is_type_only);
2661 assert!(import.local_name.is_empty());
2662 }
2663 assert_eq!(imports[200].source, "./deep");
2665 assert_eq!(
2666 imports[200].imported_name,
2667 ImportedName::Named("D".to_string())
2668 );
2669 }
2670
2671 #[test]
2672 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
2673 let cases = [
2677 " * @type {import('./a').A} and {plain} then {import('./b').B}",
2678 " * @remarks { import('./skip') } @param x {import('./c').C}",
2679 " * text } stray close { import('./d').D } trailing",
2680 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
2681 ];
2682 for body in cases {
2683 let bytes = body.as_bytes();
2684 let mut cursor = 0;
2685 while let Some(rel) = body[cursor..].find("import(") {
2686 let import_pos = cursor + rel;
2687 let mut fresh = Vec::new();
2689 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
2690 match b {
2691 b'{' => fresh.push(idx),
2692 b'}' => {
2693 fresh.pop();
2694 }
2695 _ => {}
2696 }
2697 }
2698 let mut stack = Vec::new();
2699 let mut scanned = 0;
2700 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
2701 assert_eq!(
2702 stack.last().copied(),
2703 fresh.last().copied(),
2704 "enclosing brace mismatch at {import_pos} in {body:?}"
2705 );
2706 cursor = import_pos + "import(".len();
2707 }
2708 }
2709 }
2710}