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 apply_alchemy_mains(
148 &mut module,
149 crate::alchemy::extract_alchemy_mains(path, source),
150 );
151 if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
155 module.load_return_keys = Vec::new();
156 module.has_unharvestable_load = false;
157 }
158 module
159}
160
161const ALCHEMY_MAIN_BINDING_PREFIX: &str = "#alchemy-main-";
165
166fn apply_alchemy_mains(module: &mut ModuleInfo, mains: crate::alchemy::AlchemyMains) {
177 if mains.is_empty() {
178 return;
179 }
180 if !mains.own_exports.is_empty() {
181 let mut exports = module.exports.to_vec();
182 for export in &mut exports {
183 if mains
184 .own_exports
185 .iter()
186 .any(|name| export.name.matches_str(name))
187 {
188 export.is_side_effect_used = true;
189 }
190 }
191 module.exports = exports.into();
192 }
193 if mains.targets.is_empty() {
194 return;
195 }
196 let mut whole_object_uses = module.whole_object_uses.to_vec();
197 let mut semantic_facts = module.semantic_facts.to_vec();
198 for (index, (source, span)) in mains.targets.into_iter().enumerate() {
199 let local_name = format!("{ALCHEMY_MAIN_BINDING_PREFIX}{index}");
200 whole_object_uses.push(local_name.clone());
201 semantic_facts.push(fallow_types::extract::SemanticFact::ImportLoadKindOverride(
202 fallow_types::extract::ImportLoadKindOverrideFact {
203 span_start: span.start,
204 kind: fallow_types::extract::ImportLoadKind::OutOfThread,
205 },
206 ));
207 module
208 .dynamic_imports
209 .push(fallow_types::extract::DynamicImportInfo {
210 source,
211 span,
212 destructured_names: Vec::new(),
213 local_name: Some(local_name),
214 is_speculative: true,
215 value_escapes: false,
216 });
217 }
218 module.whole_object_uses = whole_object_uses.into();
219 module.semantic_facts = semantic_facts.into();
220}
221
222fn is_sveltekit_page_load_file(path: &Path) -> bool {
227 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
228 return false;
229 };
230 matches!(
231 name,
232 "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
233 )
234}
235
236fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
237 if is_sveltekit_page_load_file(path) {
238 return RouteLoadHarvestMode::SvelteKitPage;
239 }
240 if is_conventional_route_loader_file(path) {
241 return RouteLoadHarvestMode::ConventionalRoute;
242 }
243 RouteLoadHarvestMode::None
244}
245
246fn is_conventional_route_loader_file(path: &Path) -> bool {
247 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
248 return false;
249 };
250 if name.starts_with('+') {
251 return false;
252 }
253 if !matches!(
254 path.extension().and_then(|ext| ext.to_str()),
255 Some("ts" | "tsx" | "js" | "jsx")
256 ) {
257 return false;
258 }
259 if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
260 && path
261 .parent()
262 .and_then(|parent| parent.file_name())
263 .and_then(|part| part.to_str())
264 .is_some_and(|part| matches!(part, "app" | "src"))
265 {
266 return true;
267 }
268 path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
269}
270
271fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
272 let mut previous = None;
273 for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
274 if previous == Some(app_dir) && part == "routes" {
275 return true;
276 }
277 previous = Some(part);
278 }
279 false
280}
281
282fn parse_source_to_module_inner(
283 file_id: FileId,
284 path: &Path,
285 source: &str,
286 content_hash: u64,
287 need_complexity: bool,
288 flag_patterns: &FlagPatterns,
289) -> ModuleInfo {
290 let source = crate::strip_bom(source);
291 if let Some(module) =
292 parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
293 {
294 return module;
295 }
296
297 let stripped_glimmer_source = is_glimmer_file(path)
298 .then(|| strip_glimmer_templates(source))
299 .flatten();
300 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
301 let source_type = source_type_for_path(path);
302 let allocator = Allocator::default();
303 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
304 let mut degradation = ParseDegradation::from_parser(&parser_return);
305
306 let mut parsed_suppressions =
307 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
308
309 let (mut extractor, mut semantic_usage) =
310 build_primary_extractor(&parser_return.program, path, source, source_type);
311
312 let line_offsets = fallow_types::extract::compute_line_offsets(source);
313
314 let (mut complexity, mut flags) = compute_primary_complexity_and_flags(
315 &parser_return.program,
316 parser_source,
317 &extractor.inline_template_findings,
318 &line_offsets,
319 need_complexity,
320 flag_patterns,
321 );
322
323 apply_jsx_retry_or_jsdoc(
324 &JsxRetryOrJsdocInput {
325 path,
326 parser_source,
327 source_type,
328 need_complexity,
329 line_offsets: &line_offsets,
330 comments: &parser_return.program.comments,
331 source,
332 export_statements: &crate::jsdoc_attach::export_statement_spans(&parser_return.program),
333 flag_patterns,
334 },
335 &mut ParseOutputs {
336 extractor: &mut extractor,
337 semantic_usage: &mut semantic_usage,
338 complexity: &mut complexity,
339 flags: &mut flags,
340 parsed_suppressions: &mut parsed_suppressions,
341 degradation: &mut degradation,
342 },
343 );
344
345 assemble_module_info(ModuleAssemblyInput {
346 extractor,
347 file_id,
348 content_hash,
349 parsed_suppressions,
350 semantic_usage,
351 line_offsets,
352 complexity,
353 flags,
354 degradation,
355 })
356}
357
358struct JsxRetryOrJsdocInput<'a> {
360 path: &'a Path,
361 parser_source: &'a str,
362 source_type: SourceType,
363 need_complexity: bool,
364 line_offsets: &'a [u32],
365 comments: &'a [Comment],
366 source: &'a str,
367 export_statements: &'a [oxc_span::Span],
368 flag_patterns: &'a FlagPatterns,
369}
370
371struct ModuleAssemblyInput {
372 extractor: ModuleInfoExtractor,
373 file_id: FileId,
374 content_hash: u64,
375 parsed_suppressions: crate::suppress::ParsedSuppressions,
376 semantic_usage: SemanticUsage,
377 line_offsets: Vec<u32>,
378 complexity: Vec<FunctionComplexity>,
379 flags: ExtractedFlags,
380 degradation: ParseDegradation,
381}
382
383#[derive(Debug, Clone, Copy, Default)]
392struct ParseDegradation {
393 error_count: u32,
394 panicked: bool,
395}
396
397impl ParseDegradation {
398 fn from_parser(parser_return: &oxc_parser::ParserReturn<'_>) -> Self {
399 Self {
400 error_count: u32::try_from(parser_return.diagnostics.len()).unwrap_or(u32::MAX),
401 panicked: parser_return.fatal_error,
402 }
403 }
404}
405
406fn build_primary_extractor(
409 program: &Program<'_>,
410 path: &Path,
411 source: &str,
412 source_type: SourceType,
413) -> (ModuleInfoExtractor, SemanticUsage) {
414 let mut extractor = ModuleInfoExtractor::new();
415 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
416 extractor.jsx_capable = source_type.is_jsx();
420 extractor.visit_program(program);
421 extractor.resolve_pending_local_export_specifiers();
422
423 let template_used_imports =
424 collect_glimmer_template_into_extractor(&mut extractor, path, source);
425 let semantic_usage =
426 compute_semantic_usage_for_extractor(program, &mut extractor, &template_used_imports);
427 extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
428 (extractor, semantic_usage)
429}
430
431fn compute_primary_complexity_and_flags(
434 program: &Program<'_>,
435 parser_source: &str,
436 inline_template_findings: &[crate::visitor::InlineTemplateFinding],
437 line_offsets: &[u32],
438 need_complexity: bool,
439 flag_patterns: &FlagPatterns,
440) -> (Vec<FunctionComplexity>, ExtractedFlags) {
441 let mut complexity = if need_complexity {
442 crate::complexity::compute_complexity(program, parser_source, line_offsets)
443 } else {
444 Vec::new()
445 };
446 if need_complexity {
447 append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
448 }
449
450 let flags = crate::flags::extract_flags(program, line_offsets, flag_patterns);
451 (complexity, flags)
452}
453
454struct ParseOutputs<'a> {
456 extractor: &'a mut ModuleInfoExtractor,
457 semantic_usage: &'a mut SemanticUsage,
458 complexity: &'a mut Vec<FunctionComplexity>,
459 flags: &'a mut ExtractedFlags,
460 parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
461 degradation: &'a mut ParseDegradation,
462}
463
464fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
468 let retry_input = JsxRetryInput {
469 path: input.path,
470 source: input.source,
471 parser_source: input.parser_source,
472 source_type: input.source_type,
473 total_extracted: outputs.extractor.exports.len()
474 + outputs.extractor.imports.len()
475 + outputs.extractor.re_exports.len(),
476 need_complexity: input.need_complexity,
477 line_offsets: input.line_offsets,
478 flag_patterns: input.flag_patterns,
479 };
480 let Some(retry) = parse_with_jsx_retry(&retry_input) else {
481 apply_jsdoc_tags_to_extractor(
482 &mut *outputs.extractor,
483 input.comments,
484 input.source,
485 input.export_statements,
486 );
487 return;
488 };
489 *outputs.extractor = retry.extractor;
490 *outputs.semantic_usage = retry.semantic_usage;
491 *outputs.complexity = retry.complexity;
492 *outputs.flags = retry.flags;
493 *outputs.parsed_suppressions = retry.parsed_suppressions;
494 *outputs.degradation = retry.degradation;
497}
498
499fn apply_jsdoc_tags_to_extractor(
502 extractor: &mut ModuleInfoExtractor,
503 comments: &[Comment],
504 source: &str,
505 statements: &[oxc_span::Span],
506) {
507 apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source, statements);
508 crate::jsdoc_deprecated::apply_jsdoc_deprecated_tags(
509 &mut extractor.exports,
510 comments,
511 source,
512 statements,
513 );
514 extract_jsdoc_import_types(&mut extractor.imports, comments, source);
515}
516
517fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
520 let ModuleAssemblyInput {
521 extractor,
522 file_id,
523 content_hash,
524 parsed_suppressions,
525 semantic_usage,
526 line_offsets,
527 complexity,
528 flags,
529 degradation,
530 } = input;
531 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
532 let mut contracts = semantic_usage.component_contracts;
533 if degradation.error_count > 0 || degradation.panicked {
534 for declaration in &mut contracts.declarations {
535 declaration.incomplete = true;
536 }
537 for invocation in &mut contracts.invocations {
538 invocation.unknown_props = true;
539 }
540 }
541 if !contracts.aliases.is_empty()
542 || !contracts.exports.is_empty()
543 || !contracts.declarations.is_empty()
544 || !contracts.invocations.is_empty()
545 || !contracts.escapes.is_empty()
546 || !contracts.incomplete_frameworks.is_empty()
547 || !contracts.spread_bindings.is_empty()
548 {
549 info.component_contracts = Some(Box::new(contracts));
550 }
551 info.effect_facts = semantic_usage.effect_facts;
552 info.parse_error_count = degradation.error_count;
553 info.parse_panicked = degradation.panicked;
554 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
555 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
556 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
557 info.auto_import_candidates
558 .extend(semantic_usage.auto_import_candidates);
559 info.auto_import_candidates.sort_unstable();
560 info.auto_import_candidates.dedup();
561 append_declaration_merge_facts(
562 &mut info.semantic_facts,
563 semantic_usage.declaration_merges,
564 0,
565 );
566 info.line_offsets = line_offsets;
567 info.complexity = complexity;
568 info.flag_uses = flags.flag_uses;
569 info.flag_registry_facts = flags.registry_facts;
570 info
571}
572
573pub fn append_declaration_merge_facts(
574 facts: &mut std::sync::Arc<[fallow_types::extract::SemanticFact]>,
575 mut groups: Vec<fallow_types::extract::DeclarationMergeFact>,
576 byte_offset: u32,
577) {
578 if groups.is_empty() {
579 return;
580 }
581 if byte_offset != 0 {
582 for group in &mut groups {
583 for (start, end) in &mut group.export_spans {
584 *start += byte_offset;
585 *end += byte_offset;
586 }
587 }
588 }
589 let mut merged = std::mem::take(facts).to_vec();
590 merged.extend(
591 groups
592 .into_iter()
593 .map(fallow_types::extract::SemanticFact::DeclarationMerge),
594 );
595 *facts = merged.into();
596}
597
598struct JsxRetryInput<'a> {
599 path: &'a Path,
600 source: &'a str,
601 parser_source: &'a str,
602 source_type: SourceType,
603 total_extracted: usize,
604 need_complexity: bool,
605 line_offsets: &'a [u32],
606 flag_patterns: &'a FlagPatterns,
607}
608
609fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
610 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
611 return None;
612 }
613
614 let jsx_type = if input.source_type.is_typescript() {
615 SourceType::tsx()
616 } else {
617 SourceType::jsx()
618 };
619 let allocator = Allocator::default();
620 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
621 let degradation = ParseDegradation::from_parser(&retry_return);
622 let mut extractor = ModuleInfoExtractor::new();
623 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
624 extractor.jsx_capable = true;
627 extractor.visit_program(&retry_return.program);
628 extractor.resolve_pending_local_export_specifiers();
629 let retry_total =
630 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
631 if retry_total <= input.total_extracted {
632 return None;
633 }
634
635 let template_used_imports =
636 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
637 let semantic_usage = compute_semantic_usage_for_extractor(
638 &retry_return.program,
639 &mut extractor,
640 &template_used_imports,
641 );
642 extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
643 let complexity = retry_complexity(
644 input.need_complexity,
645 &retry_return.program,
646 input.parser_source,
647 input.line_offsets,
648 &extractor,
649 );
650 let flags = crate::flags::extract_flags(
651 &retry_return.program,
652 input.line_offsets,
653 input.flag_patterns,
654 );
655 let parsed_suppressions =
656 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
657 let export_statements = crate::jsdoc_attach::export_statement_spans(&retry_return.program);
658 apply_jsdoc_visibility_tags(
659 &mut extractor.exports,
660 &retry_return.program.comments,
661 input.source,
662 &export_statements,
663 );
664 crate::jsdoc_deprecated::apply_jsdoc_deprecated_tags(
665 &mut extractor.exports,
666 &retry_return.program.comments,
667 input.source,
668 &export_statements,
669 );
670 extract_jsdoc_import_types(
671 &mut extractor.imports,
672 &retry_return.program.comments,
673 input.source,
674 );
675 Some(JsxRetryParse {
676 extractor,
677 semantic_usage,
678 complexity,
679 flags,
680 parsed_suppressions,
681 degradation,
682 })
683}
684
685fn retry_complexity(
686 need_complexity: bool,
687 program: &Program<'_>,
688 parser_source: &str,
689 line_offsets: &[u32],
690 extractor: &ModuleInfoExtractor,
691) -> Vec<FunctionComplexity> {
692 if !need_complexity {
693 return Vec::new();
694 }
695 let mut complexity =
696 crate::complexity::compute_complexity(program, parser_source, line_offsets);
697 append_inline_template_complexity(
698 &mut complexity,
699 &extractor.inline_template_findings,
700 line_offsets,
701 );
702 complexity
703}
704
705fn parse_non_js_source_to_module(
706 file_id: FileId,
707 path: &Path,
708 source: &str,
709 content_hash: u64,
710 need_complexity: bool,
711) -> Option<ModuleInfo> {
712 if is_sfc_file(path) {
713 return Some(parse_sfc_to_module(
714 file_id,
715 path,
716 source,
717 content_hash,
718 need_complexity,
719 ));
720 }
721 if is_astro_file(path) {
722 return Some(parse_astro_to_module(
723 file_id,
724 source,
725 content_hash,
726 need_complexity,
727 ));
728 }
729 if is_mdx_file(path) {
730 return Some(parse_mdx_to_module(file_id, source, content_hash));
731 }
732 if is_css_file(path) {
733 return Some(parse_css_to_module(file_id, path, source, content_hash));
734 }
735 if is_graphql_file(path) {
736 return Some(parse_graphql_to_module(file_id, source, content_hash));
737 }
738 if is_html_file(path) {
739 return Some(parse_html_to_module_with_complexity(
740 file_id,
741 source,
742 content_hash,
743 need_complexity,
744 ));
745 }
746 None
747}
748
749fn collect_glimmer_template_into_extractor(
771 extractor: &mut ModuleInfoExtractor,
772 path: &Path,
773 source: &str,
774) -> rustc_hash::FxHashSet<String> {
775 use rustc_hash::FxHashSet;
776
777 if !is_glimmer_file(path) {
778 return FxHashSet::default();
779 }
780 let template_ranges = crate::glimmer::find_template_ranges(source);
781 if template_ranges.is_empty() {
782 return FxHashSet::default();
783 }
784
785 let imported_bindings: FxHashSet<String> = extractor
786 .imports
787 .iter()
788 .filter(|import| !import.local_name.is_empty())
789 .map(|import| import.local_name.clone())
790 .collect();
791
792 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
793 source,
794 &template_ranges,
795 &imported_bindings,
796 );
797 extractor.member_accesses.extend(usage.member_accesses);
798 usage.used_bindings
799}
800
801fn append_inline_template_complexity(
812 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
813 findings: &[crate::visitor::InlineTemplateFinding],
814 line_offsets: &[u32],
815) {
816 for finding in findings {
817 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
818 &finding.template_source,
819 ) else {
820 continue;
821 };
822 let (line, col) =
823 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
824 fc.line = line;
825 fc.col = col;
826 complexity.push(fc);
827 }
828}
829
830fn apply_jsdoc_visibility_tags(
838 exports: &mut [ExportInfo],
839 comments: &[Comment],
840 source: &str,
841 statements: &[oxc_span::Span],
842) {
843 if exports.is_empty() || comments.is_empty() {
844 return;
845 }
846
847 let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
848 if tag_offsets.is_empty() {
849 return;
850 }
851 tag_offsets.sort_by_key(|&(offset, _, _)| offset);
853
854 for export in exports.iter_mut() {
855 apply_visibility_tag_to_export(export, &tag_offsets, statements);
856 }
857}
858
859fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
862 if has_public_tag(text) {
863 Some((VisibilityTag::Public, None))
864 } else if bare_jsdoc_tag_end(text, "@internal").is_some() {
865 Some((VisibilityTag::Internal, None))
866 } else if bare_jsdoc_tag_end(text, "@alpha").is_some() {
867 Some((VisibilityTag::Alpha, None))
868 } else if bare_jsdoc_tag_end(text, "@beta").is_some() {
869 Some((VisibilityTag::Beta, None))
870 } else {
871 bare_jsdoc_tag_end(text, "@expected-unused").map(|after| {
872 (
873 VisibilityTag::ExpectedUnused,
874 split_jsdoc_reason(&text[after..]),
875 )
876 })
877 }
878}
879
880fn collect_jsdoc_tag_offsets(
883 comments: &[Comment],
884 source: &str,
885) -> Vec<(u32, VisibilityTag, Option<String>)> {
886 let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
887 for comment in comments {
888 if !comment.is_jsdoc() {
889 continue;
890 }
891 let content_span = comment.content_span();
892 let start = content_span.start as usize;
893 let end = (content_span.end as usize).min(source.len());
894 if start >= end {
895 continue;
896 }
897 if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
898 tag_offsets.push((comment.attached_to, tag, reason));
899 }
900 }
901 tag_offsets
902}
903
904fn apply_visibility_tag_to_export(
906 export: &mut ExportInfo,
907 tag_offsets: &[(u32, VisibilityTag, Option<String>)],
908 statements: &[oxc_span::Span],
909) {
910 if export.span.start == 0 && export.span.end == 0 {
911 return;
912 }
913 let found = crate::jsdoc_attach::tag_index_for_export(
914 tag_offsets,
915 |&(offset, _, _)| offset,
916 export.span.start,
917 statements,
918 );
919 if let Some(idx) = found {
920 export.visibility = tag_offsets[idx].1;
921 export
922 .expected_unused_reason
923 .clone_from(&tag_offsets[idx].2);
924 }
925}
926
927fn split_jsdoc_reason(rest: &str) -> Option<String> {
928 for (idx, _) in rest.match_indices("--") {
929 let before_ok = idx == 0
930 || rest[..idx]
931 .chars()
932 .next_back()
933 .is_some_and(char::is_whitespace);
934 let after_idx = idx + 2;
935 let after_ok = after_idx == rest.len()
936 || rest[after_idx..]
937 .chars()
938 .next()
939 .is_some_and(char::is_whitespace);
940 if before_ok && after_ok {
941 let reason = rest[after_idx..].trim();
942 return if reason.is_empty() {
943 None
944 } else {
945 Some(reason.to_string())
946 };
947 }
948 }
949
950 None
951}
952
953const fn is_ident_char(b: u8) -> bool {
955 b.is_ascii_alphanumeric() || b == b'_'
956}
957
958fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
982 if comments.is_empty() {
983 return;
984 }
985
986 let mut namespaces = Vec::new();
987 for comment in comments {
988 if !comment.is_jsdoc() {
989 continue;
990 }
991 let content_span = comment.content_span();
992 let start = content_span.start as usize;
993 let end = (content_span.end as usize).min(source.len());
994 if start >= end {
995 continue;
996 }
997 let body = &source[start..end];
998 scan_jsdoc_imports_in(body, imports);
999 scan_jsdoc_import_tags_in(body, imports, &mut namespaces);
1000 }
1001 if !namespaces.is_empty() {
1002 push_jsdoc_namespace_members(imports, &namespaces, comments, source);
1003 }
1004}
1005
1006struct JsdocNamespaceImport {
1008 local: String,
1009 source: String,
1010}
1011
1012fn push_jsdoc_namespace_members(
1020 imports: &mut Vec<ImportInfo>,
1021 namespaces: &[JsdocNamespaceImport],
1022 comments: &[Comment],
1023 source: &str,
1024) {
1025 use fallow_types::extract::ImportedName;
1026
1027 let mut members: Vec<(usize, &str)> = Vec::new();
1028 for comment in comments.iter().filter(|comment| comment.is_jsdoc()) {
1029 let content_span = comment.content_span();
1030 let start = content_span.start as usize;
1031 let end = (content_span.end as usize).min(source.len());
1032 if start >= end {
1033 continue;
1034 }
1035 scan_jsdoc_namespace_members_in(&source[start..end], namespaces, &mut members);
1036 }
1037 for namespace in namespaces {
1038 imports.push(jsdoc_type_import(
1039 &namespace.source,
1040 ImportedName::SideEffect,
1041 ));
1042 }
1043 for (index, member) in members {
1044 imports.push(jsdoc_type_import(
1045 &namespaces[index].source,
1046 ImportedName::Named(member.to_string()),
1047 ));
1048 }
1049}
1050
1051fn scan_jsdoc_namespace_members_in<'a>(
1055 body: &'a str,
1056 namespaces: &[JsdocNamespaceImport],
1057 members: &mut Vec<(usize, &'a str)>,
1058) {
1059 let bytes = body.as_bytes();
1060 let mut brace_stack: Vec<usize> = Vec::new();
1061 let mut scanned = 0;
1062 let mut pos = 0;
1063 while pos < bytes.len() {
1064 let starts_ident =
1065 (bytes[pos].is_ascii_alphabetic() || bytes[pos] == b'_' || bytes[pos] == b'$')
1066 && (pos == 0
1067 || !(is_ident_char(bytes[pos - 1]) || matches!(bytes[pos - 1], b'$' | b'.')));
1068 if !starts_ident {
1069 pos += 1;
1070 continue;
1071 }
1072 let Some((ident, after)) = take_js_identifier(&body[pos..]) else {
1073 pos += 1;
1074 continue;
1075 };
1076 let ident_pos = pos;
1077 pos += ident.len();
1078 let Some(index) = namespaces
1079 .iter()
1080 .position(|namespace| namespace.local == ident)
1081 else {
1082 continue;
1083 };
1084 let Some((member, _)) = after.strip_prefix('.').and_then(take_js_identifier) else {
1085 continue;
1086 };
1087 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, ident_pos);
1088 if !is_inside_jsdoc_type_brace_group(bytes, ident_pos, brace_stack.last().copied()) {
1089 continue;
1090 }
1091 let entry = (index, member);
1092 if !members.contains(&entry) {
1093 members.push(entry);
1094 }
1095 pos += 1 + member.len();
1096 }
1097}
1098
1099const JSDOC_IMPORT_TAG: &str = "@import";
1100
1101fn scan_jsdoc_import_tags_in(
1116 body: &str,
1117 imports: &mut Vec<ImportInfo>,
1118 namespaces: &mut Vec<JsdocNamespaceImport>,
1119) {
1120 let bytes = body.as_bytes();
1121 let mut cursor = 0;
1122 while let Some(rel) = body[cursor..].find(JSDOC_IMPORT_TAG) {
1123 let tag_pos = cursor + rel;
1124 cursor = tag_pos + JSDOC_IMPORT_TAG.len();
1125 let starts_line = strip_jsdoc_line_prefix(line_prefix_before(bytes, tag_pos)).is_empty();
1126 let ends_tag = bytes
1127 .get(cursor)
1128 .is_none_or(|&b| b.is_ascii_whitespace() || b == b'{' || b == b'*');
1129 if !starts_line || !ends_tag {
1130 continue;
1131 }
1132 let clause = jsdoc_import_tag_clause(&body[cursor..]);
1133 let Some(parsed) = parse_jsdoc_import_clause(&clause) else {
1134 continue;
1135 };
1136 if let Some(local) = parsed.namespace {
1137 namespaces.push(JsdocNamespaceImport {
1138 local: local.to_string(),
1139 source: parsed.source.to_string(),
1140 });
1141 } else if parsed.names.is_empty() {
1142 imports.push(jsdoc_type_import(
1143 parsed.source,
1144 fallow_types::extract::ImportedName::SideEffect,
1145 ));
1146 }
1147 for name in parsed.names {
1148 imports.push(jsdoc_type_import(parsed.source, name));
1149 }
1150 }
1151}
1152
1153fn jsdoc_import_tag_clause(rest: &str) -> String {
1156 let mut lines = rest.split('\n');
1157 let mut clause = lines.next().unwrap_or_default().to_string();
1158 for line in lines {
1159 let line = strip_jsdoc_line_prefix(line);
1160 if line.starts_with('@') {
1161 break;
1162 }
1163 clause.push(' ');
1164 clause.push_str(line);
1165 }
1166 clause
1167}
1168
1169struct JsdocImportClause<'a> {
1171 names: Vec<fallow_types::extract::ImportedName>,
1173 namespace: Option<&'a str>,
1175 source: &'a str,
1176}
1177
1178fn parse_jsdoc_import_clause(clause: &str) -> Option<JsdocImportClause<'_>> {
1181 use fallow_types::extract::ImportedName;
1182
1183 let mut names = Vec::new();
1184 let mut namespace = None;
1185 let mut rest = clause.trim_start();
1186 if let Some((ident, after)) = take_js_identifier(rest)
1187 && ident != "from"
1188 {
1189 names.push(ImportedName::Default);
1190 rest = after.trim_start();
1191 match rest.strip_prefix(',') {
1192 Some(after_comma) => rest = after_comma.trim_start(),
1193 None => {
1194 return parse_jsdoc_import_from(rest).map(|source| JsdocImportClause {
1195 names,
1196 namespace,
1197 source,
1198 });
1199 }
1200 }
1201 }
1202 if let Some(after_star) = rest.strip_prefix('*') {
1203 let after_as = after_star.trim_start().strip_prefix("as")?;
1204 let (local, after_ns) = take_js_identifier(after_as.trim_start())?;
1205 namespace = Some(local);
1206 rest = after_ns;
1207 } else if let Some(after_brace) = rest.strip_prefix('{') {
1208 let close = after_brace.find('}')?;
1209 for specifier in after_brace[..close].split(',') {
1210 if let Some(name) = jsdoc_import_specifier_name(specifier) {
1211 names.push(name);
1212 }
1213 }
1214 rest = &after_brace[close + 1..];
1215 } else if names.is_empty() {
1216 return None;
1217 }
1218 parse_jsdoc_import_from(rest.trim_start()).map(|source| JsdocImportClause {
1219 names,
1220 namespace,
1221 source,
1222 })
1223}
1224
1225fn jsdoc_import_specifier_name(specifier: &str) -> Option<fallow_types::extract::ImportedName> {
1228 use fallow_types::extract::ImportedName;
1229
1230 let specifier = specifier.trim();
1231 let specifier = specifier
1232 .strip_prefix("type")
1233 .filter(|after| after.starts_with(char::is_whitespace))
1234 .map_or(specifier, str::trim_start);
1235 let name = match specifier.as_bytes().first()? {
1236 quote @ (b'\'' | b'"') => {
1237 let inner = &specifier[1..];
1238 &inner[..inner.find(*quote as char)?]
1239 }
1240 _ => take_js_identifier(specifier)?.0,
1241 };
1242 if name == "default" {
1243 return Some(ImportedName::Default);
1244 }
1245 Some(ImportedName::Named(name.to_string()))
1246}
1247
1248fn parse_jsdoc_import_from(rest: &str) -> Option<&str> {
1250 let (keyword, after) = take_js_identifier(rest)?;
1251 if keyword != "from" {
1252 return None;
1253 }
1254 let after = after.trim_start();
1255 let quote = after.chars().next().filter(|c| *c == '\'' || *c == '"')?;
1256 let inner = &after[1..];
1257 let source = &inner[..inner.find(quote)?];
1258 (!source.is_empty()).then_some(source)
1259}
1260
1261fn take_js_identifier(text: &str) -> Option<(&str, &str)> {
1264 let bytes = text.as_bytes();
1265 if !bytes
1266 .first()
1267 .is_some_and(|&b| b.is_ascii_alphabetic() || b == b'_' || b == b'$')
1268 {
1269 return None;
1270 }
1271 let end = bytes
1272 .iter()
1273 .position(|&b| !(is_ident_char(b) || b == b'$'))
1274 .unwrap_or(bytes.len());
1275 Some(text.split_at(end))
1276}
1277
1278fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
1286 let bytes = body.as_bytes();
1287 let mut cursor = 0;
1288 let mut brace_stack: Vec<usize> = Vec::new();
1295 let mut scanned = 0;
1296 while let Some(rel) = body[cursor..].find("import(") {
1297 let import_pos = cursor + rel;
1298 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
1299 if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
1300 cursor = import_pos + "import(".len();
1301 continue;
1302 }
1303 let open = import_pos + "import(".len();
1304 match locate_jsdoc_import_path(body, bytes, open) {
1305 JsdocImportScan::Stop => break,
1306 JsdocImportScan::Skip(next) => {
1307 cursor = next;
1308 }
1309 JsdocImportScan::Found { path, after_paren } => {
1310 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
1311 }
1312 }
1313 }
1314}
1315
1316enum JsdocImportScan<'a> {
1319 Stop,
1321 Skip(usize),
1323 Found { path: &'a str, after_paren: usize },
1325}
1326
1327fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
1330 if open >= bytes.len() {
1331 return JsdocImportScan::Stop;
1332 }
1333 let mut i = open;
1334 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1335 i += 1;
1336 }
1337 if i >= bytes.len() {
1338 return JsdocImportScan::Stop;
1339 }
1340 let quote = bytes[i];
1341 if quote != b'\'' && quote != b'"' {
1342 return JsdocImportScan::Skip(open);
1343 }
1344 let path_start = i + 1;
1345 let Some(rel_close) = body[path_start..].find(quote as char) else {
1346 return JsdocImportScan::Stop;
1347 };
1348 let path_end = path_start + rel_close;
1349 let path = &body[path_start..path_end];
1350 if path.is_empty() {
1351 return JsdocImportScan::Skip(path_end + 1);
1352 }
1353 let mut j = path_end + 1;
1354 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1355 j += 1;
1356 }
1357 if j >= bytes.len() || bytes[j] != b')' {
1358 return JsdocImportScan::Skip(path_end + 1);
1359 }
1360 j += 1;
1361 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1362 j += 1;
1363 }
1364 JsdocImportScan::Found {
1365 path,
1366 after_paren: j,
1367 }
1368}
1369
1370fn resolve_jsdoc_import(
1373 body: &str,
1374 bytes: &[u8],
1375 after_paren: usize,
1376 path: &str,
1377 imports: &mut Vec<ImportInfo>,
1378) -> usize {
1379 let mut j = after_paren;
1380 if j >= bytes.len() || bytes[j] != b'.' {
1381 imports.push(jsdoc_type_import(
1382 path,
1383 fallow_types::extract::ImportedName::SideEffect,
1384 ));
1385 return after_paren;
1386 }
1387 j += 1;
1388 let name_start = j;
1389 while j < bytes.len() && is_ident_char(bytes[j]) {
1390 j += 1;
1391 }
1392 if name_start == j {
1393 return after_paren;
1396 }
1397 let member = &body[name_start..j];
1398 imports.push(jsdoc_type_import(
1399 path,
1400 fallow_types::extract::ImportedName::Named(member.to_string()),
1401 ));
1402 j
1403}
1404
1405fn jsdoc_type_import(
1409 source: &str,
1410 imported_name: fallow_types::extract::ImportedName,
1411) -> ImportInfo {
1412 ImportInfo {
1413 source: source.to_string(),
1414 imported_name,
1415 local_name: String::new(),
1416 is_type_only: true,
1417 is_type_only_star: false,
1418 from_style: false,
1419 span: oxc_span::Span::default(),
1420 source_span: oxc_span::Span::default(),
1421 }
1422}
1423
1424fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
1430 let Some(open_brace) = open_brace else {
1431 return false;
1432 };
1433
1434 let prefix = line_prefix_before(body, open_brace);
1435 if jsdoc_line_prefix_has_type_tag(prefix) {
1436 return true;
1437 }
1438
1439 strip_jsdoc_line_prefix(prefix).is_empty()
1440 && preceding_jsdoc_line_has_type_tag(body, open_brace)
1441 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
1442}
1443
1444fn advance_jsdoc_brace_stack(
1454 body: &[u8],
1455 stack: &mut Vec<usize>,
1456 scanned: &mut usize,
1457 up_to: usize,
1458) {
1459 let up_to = up_to.min(body.len());
1460 while *scanned < up_to {
1461 match body[*scanned] {
1462 b'{' => stack.push(*scanned),
1463 b'}' => {
1464 stack.pop();
1465 }
1466 _ => {}
1467 }
1468 *scanned += 1;
1469 }
1470}
1471
1472fn line_prefix_before(body: &[u8], pos: usize) -> &str {
1473 let start = body[..pos]
1474 .iter()
1475 .rposition(|&b| b == b'\n')
1476 .map_or(0, |idx| idx + 1);
1477 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
1478}
1479
1480fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
1481 let trimmed = prefix.trim_start();
1482 trimmed
1483 .strip_prefix('*')
1484 .map_or(trimmed, |rest| rest.trim_start())
1485}
1486
1487fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
1488 const TYPE_TAGS: [&str; 17] = [
1489 "@arg",
1490 "@argument",
1491 "@augments",
1492 "@callback",
1493 "@enum",
1494 "@extends",
1495 "@implements",
1496 "@param",
1497 "@property",
1498 "@prop",
1499 "@return",
1500 "@returns",
1501 "@satisfies",
1502 "@template",
1503 "@this",
1504 "@type",
1505 "@typedef",
1506 ];
1507
1508 let prefix = strip_jsdoc_line_prefix(prefix);
1509 TYPE_TAGS
1510 .iter()
1511 .any(|tag| bare_jsdoc_tag_end(prefix, tag).is_some())
1512}
1513
1514fn bare_jsdoc_tag_end(text: &str, tag: &str) -> Option<usize> {
1517 text.match_indices(tag)
1518 .map(|(idx, _)| idx + tag.len())
1519 .find(|&after| after >= text.len() || !is_ident_char(text.as_bytes()[after]))
1520}
1521
1522fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1523 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1524 return false;
1525 };
1526
1527 let line_start = body[..line_end]
1528 .iter()
1529 .rposition(|&b| b == b'\n')
1530 .map_or(0, |idx| idx + 1);
1531
1532 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1533}
1534
1535fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1536 let mut at_line_start = true;
1537 let mut i = start.min(body.len());
1538 let end = end.min(body.len());
1539 while i < end {
1540 match body[i] {
1541 b'\n' => {
1542 at_line_start = true;
1543 i += 1;
1544 }
1545 b'\r' | b'\t' | b' ' => {
1546 i += 1;
1547 }
1548 b'*' if at_line_start => {
1549 at_line_start = false;
1550 i += 1;
1551 }
1552 _ => return false,
1553 }
1554 }
1555 true
1556}
1557
1558fn has_public_tag(comment_text: &str) -> bool {
1560 if bare_jsdoc_tag_end(comment_text, "@public").is_some() {
1561 return true;
1562 }
1563 for (i, _) in comment_text.match_indices("@api") {
1564 let after = i + "@api".len();
1565 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1566 let rest = comment_text[after..].trim_start();
1567 if rest.starts_with("public") {
1568 let after_public = "public".len();
1569 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1570 return true;
1571 }
1572 }
1573 }
1574 }
1575 false
1576}
1577
1578#[derive(Debug, Default, PartialEq, Eq)]
1579pub struct ImportBindingUsage {
1580 pub unused: Vec<String>,
1581 pub type_referenced: Vec<String>,
1582 pub value_referenced: Vec<String>,
1583}
1584
1585#[derive(Debug, Default, PartialEq, Eq)]
1594pub struct MockApiReferenceSpans {
1595 pub(crate) mock_bindings: rustc_hash::FxHashSet<Span>,
1596 pub(crate) vitest_namespaces: rustc_hash::FxHashSet<Span>,
1597}
1598
1599#[derive(Debug, Default, PartialEq, Eq)]
1600pub struct SemanticUsage {
1601 pub import_binding_usage: ImportBindingUsage,
1602 pub auto_import_candidates: Vec<String>,
1603 pub declaration_merges: Vec<fallow_types::extract::DeclarationMergeFact>,
1604 pub(crate) mock_api_reference_spans: MockApiReferenceSpans,
1605 pub(crate) module_binding_reference_spans: rustc_hash::FxHashSet<Span>,
1606 pub(crate) subclass_binding_reference_spans: rustc_hash::FxHashMap<Span, Span>,
1607 pub(crate) subclass_namespace_reference_spans: rustc_hash::FxHashSet<Span>,
1608 pub(crate) subclass_immutable_reference_spans: rustc_hash::FxHashSet<Span>,
1609 pub(crate) subclass_binding_writes: rustc_hash::FxHashMap<Span, Vec<Span>>,
1610 pub(crate) builtin_container_reference_spans: rustc_hash::FxHashSet<Span>,
1611 pub(crate) imported_call_reference_spans: rustc_hash::FxHashSet<Span>,
1612 pub(crate) import_binding_references: Vec<fallow_types::extract::ImportBindingReference>,
1615 pub(crate) unreferenced_import_equals_bindings: Vec<String>,
1620 pub(crate) component_contracts: fallow_types::extract::ComponentContractFacts,
1621 pub(crate) effect_facts: Option<Box<fallow_types::extract::EffectFacts>>,
1622}
1623
1624pub fn compute_semantic_usage_for_extractor(
1625 program: &Program<'_>,
1626 extractor: &mut ModuleInfoExtractor,
1627 template_used: &rustc_hash::FxHashSet<String>,
1628) -> SemanticUsage {
1629 let computed_enum_key_spans = extractor.computed_enum_key_reference_spans();
1630 let subclass_candidates = extractor.subclass_reference_candidates();
1631 let callback_candidates = extractor.callback_capture_reference_candidates();
1632 let imported_call_candidates = extractor.imported_call_reference_candidates();
1633 let require_namespace_bindings = extractor.require_namespace_bindings();
1634 let mut semantic_usage = compute_semantic_usage_with_candidates(
1635 program,
1636 &extractor.imports,
1637 &extractor.exports,
1638 &require_namespace_bindings,
1639 template_used,
1640 SemanticReferenceCandidates {
1641 module_bindings: &computed_enum_key_spans,
1642 subclass_bindings: &subclass_candidates,
1643 callback_captures: &callback_candidates,
1644 imported_calls: &imported_call_candidates,
1645 },
1646 );
1647 extractor.resolve_computed_enum_key_uses(&semantic_usage.module_binding_reference_spans);
1648 extractor.set_subclass_binding_provenance(
1649 std::mem::take(&mut semantic_usage.subclass_binding_reference_spans),
1650 std::mem::take(&mut semantic_usage.subclass_namespace_reference_spans),
1651 std::mem::take(&mut semantic_usage.subclass_immutable_reference_spans),
1652 std::mem::take(&mut semantic_usage.builtin_container_reference_spans),
1653 std::mem::take(&mut semantic_usage.subclass_binding_writes),
1654 );
1655 extractor.resolve_imported_call_sites(&semantic_usage.imported_call_reference_spans);
1656 extractor.set_import_binding_references(std::mem::take(
1657 &mut semantic_usage.import_binding_references,
1658 ));
1659 report_unreferenced_import_equals_bindings(
1660 &mut semantic_usage,
1661 &extractor.exported_import_equals_names,
1662 );
1663 semantic_usage
1664}
1665
1666fn report_unreferenced_import_equals_bindings(
1681 semantic_usage: &mut SemanticUsage,
1682 exported_import_equals_names: &[String],
1683) {
1684 let unreferenced = std::mem::take(&mut semantic_usage.unreferenced_import_equals_bindings);
1685 if unreferenced.is_empty() {
1686 return;
1687 }
1688 let unused = &mut semantic_usage.import_binding_usage.unused;
1689 unused.extend(unreferenced.into_iter().filter(|name| {
1690 !exported_import_equals_names
1691 .iter()
1692 .any(|exported| exported == name)
1693 }));
1694 unused.sort_unstable();
1698 unused.dedup();
1699}
1700
1701#[derive(Clone, Copy)]
1702struct SemanticReferenceCandidates<'a> {
1703 module_bindings: &'a rustc_hash::FxHashSet<Span>,
1704 subclass_bindings: &'a rustc_hash::FxHashSet<Span>,
1705 callback_captures: &'a rustc_hash::FxHashSet<Span>,
1706 imported_calls: &'a rustc_hash::FxHashSet<Span>,
1707}
1708
1709#[derive(Default)]
1710struct SubclassReferenceSpans {
1711 bindings: rustc_hash::FxHashMap<Span, Span>,
1712 namespaces: rustc_hash::FxHashSet<Span>,
1713 immutable: rustc_hash::FxHashSet<Span>,
1714 writes: rustc_hash::FxHashMap<Span, Vec<Span>>,
1715 builtin_containers: rustc_hash::FxHashSet<Span>,
1716}
1717
1718fn subclass_reference_spans(
1719 semantic: &oxc_semantic::Semantic<'_>,
1720 imports: &[ImportInfo],
1721 references: SemanticReferenceCandidates<'_>,
1722) -> SubclassReferenceSpans {
1723 use rustc_hash::FxHashSet;
1724 let candidates = references.subclass_bindings;
1725 let callback_captures = references.callback_captures;
1726 let scoping = semantic.scoping();
1727 let mut subclass_binding_reference_spans = rustc_hash::FxHashMap::default();
1728 let mut subclass_namespace_reference_spans = FxHashSet::default();
1729 let mut subclass_immutable_reference_spans = FxHashSet::default();
1730 let mut writes = rustc_hash::FxHashMap::default();
1731 if !candidates.is_empty() {
1732 let namespace_symbols: FxHashSet<_> = imports
1733 .iter()
1734 .filter(|import| matches!(import.imported_name, ImportedName::Namespace))
1735 .filter_map(|import| {
1736 crate::binding_references::admitted_import_symbol(semantic, import)
1737 })
1738 .collect();
1739 for symbol in scoping.symbol_ids() {
1740 let mut admitted = false;
1741 let immutable = !scoping
1742 .get_resolved_references(symbol)
1743 .any(|reference| reference.is_write());
1744 for reference in scoping.get_resolved_references(symbol) {
1745 let AstKind::IdentifierReference(identifier) =
1746 semantic.nodes().kind(reference.node_id())
1747 else {
1748 continue;
1749 };
1750 if !candidates.contains(&identifier.span) {
1751 continue;
1752 }
1753 admitted |= callback_captures.contains(&identifier.span);
1754 subclass_binding_reference_spans
1755 .insert(identifier.span, scoping.symbol_span(symbol));
1756 if immutable {
1757 subclass_immutable_reference_spans.insert(identifier.span);
1758 }
1759 if reference.is_value() && namespace_symbols.contains(&symbol) {
1760 subclass_namespace_reference_spans.insert(identifier.span);
1761 }
1762 }
1763 if admitted && !immutable {
1764 let mut spans: Vec<_> = scoping
1765 .get_resolved_references(symbol)
1766 .filter(|reference| reference.is_write())
1767 .filter_map(
1768 |reference| match semantic.nodes().kind(reference.node_id()) {
1769 AstKind::IdentifierReference(identifier) => Some(identifier.span),
1770 _ => None,
1771 },
1772 )
1773 .collect();
1774 spans.sort_unstable_by_key(|span| span.start);
1775 writes.insert(scoping.symbol_span(symbol), spans);
1776 }
1777 }
1778 }
1779
1780 let mut builtin_containers = FxHashSet::default();
1781 for (name, references) in scoping.root_unresolved_references() {
1782 if !matches!(name.as_str(), "Array" | "ReadonlyArray") {
1783 continue;
1784 }
1785 for reference in references {
1786 let reference = scoping.get_reference(*reference);
1787 if let AstKind::IdentifierReference(identifier) =
1788 semantic.nodes().kind(reference.node_id())
1789 && candidates.contains(&identifier.span)
1790 {
1791 builtin_containers.insert(identifier.span);
1792 }
1793 }
1794 }
1795 SubclassReferenceSpans {
1796 bindings: subclass_binding_reference_spans,
1797 namespaces: subclass_namespace_reference_spans,
1798 immutable: subclass_immutable_reference_spans,
1799 writes,
1800 builtin_containers,
1801 }
1802}
1803
1804fn compute_semantic_usage_with_candidates(
1805 program: &Program<'_>,
1806 imports: &[ImportInfo],
1807 exports: &[ExportInfo],
1808 require_namespace_bindings: &[String],
1809 template_used: &rustc_hash::FxHashSet<String>,
1810 candidates: SemanticReferenceCandidates<'_>,
1811) -> SemanticUsage {
1812 use oxc_semantic::SemanticBuilder;
1813 use rustc_hash::FxHashSet;
1814
1815 let semantic_ret = SemanticBuilder::new().with_build_nodes(true).build(program);
1816 let semantic = semantic_ret.semantic;
1817 let scoping = semantic.scoping();
1818 let root_scope = scoping.root_scope_id();
1819
1820 let mut unused = Vec::new();
1821 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1822 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1823 for import in imports {
1824 if import.local_name.is_empty() {
1825 continue;
1826 }
1827 if let Some((has_references, has_type_references, has_value_references)) =
1828 binding_reference_usage(scoping, &import.local_name)
1829 {
1830 if !has_references {
1831 if !template_used.contains(&import.local_name) {
1832 unused.push(import.local_name.clone());
1833 }
1834 continue;
1835 }
1836
1837 if has_type_references {
1838 type_referenced_bindings.insert(import.local_name.clone());
1839 }
1840 if has_value_references {
1841 value_referenced_bindings.insert(import.local_name.clone());
1842 }
1843 }
1844 }
1845
1846 let import_equals = classify_import_equals_bindings(
1847 scoping,
1848 require_namespace_bindings,
1849 template_used,
1850 &mut type_referenced_bindings,
1851 &mut value_referenced_bindings,
1852 );
1853
1854 unused.sort_unstable();
1855
1856 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1857 type_referenced_bindings.sort_unstable();
1858
1859 let mut value_referenced_bindings: Vec<String> =
1860 value_referenced_bindings.into_iter().collect();
1861 value_referenced_bindings.sort_unstable();
1862 let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1863 let declaration_merges = declaration_merge_facts(&semantic);
1864 let mut module_binding_reference_spans = FxHashSet::default();
1865 if !candidates.module_bindings.is_empty() {
1866 for symbol_id in scoping.symbol_ids() {
1867 if scoping.symbol_scope_id(symbol_id) != root_scope {
1868 continue;
1869 }
1870 module_binding_reference_spans.extend(
1871 scoping
1872 .get_resolved_references(symbol_id)
1873 .filter_map(|reference| {
1874 let AstKind::IdentifierReference(identifier) =
1875 semantic.nodes().kind(reference.node_id())
1876 else {
1877 return None;
1878 };
1879 candidates
1880 .module_bindings
1881 .contains(&identifier.span)
1882 .then_some(identifier.span)
1883 }),
1884 );
1885 }
1886 }
1887
1888 let subclass = subclass_reference_spans(&semantic, imports, candidates);
1889
1890 let imported_call_reference_spans =
1891 imported_call_reference_spans(&semantic, imports, candidates.imported_calls);
1892 let import_binding_references = crate::binding_references::collect(
1893 &semantic,
1894 imports,
1895 exports,
1896 &imported_call_reference_spans,
1897 );
1898 SemanticUsage {
1899 import_binding_usage: ImportBindingUsage {
1900 unused,
1901 type_referenced: type_referenced_bindings,
1902 value_referenced: value_referenced_bindings,
1903 },
1904 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1905 declaration_merges,
1906 mock_api_reference_spans,
1907 module_binding_reference_spans,
1908 subclass_binding_reference_spans: subclass.bindings,
1909 subclass_namespace_reference_spans: subclass.namespaces,
1910 subclass_immutable_reference_spans: subclass.immutable,
1911 subclass_binding_writes: subclass.writes,
1912 builtin_container_reference_spans: subclass.builtin_containers,
1913 imported_call_reference_spans,
1914 import_binding_references,
1915 unreferenced_import_equals_bindings: import_equals.unreferenced,
1916 component_contracts: crate::component_contracts::collect(&semantic, imports, exports),
1917 effect_facts: crate::effect_facts::collect(&semantic, program, imports, exports),
1918 }
1919}
1920
1921fn imported_call_reference_spans(
1923 semantic: &oxc_semantic::Semantic<'_>,
1924 imports: &[ImportInfo],
1925 candidates: &rustc_hash::FxHashSet<Span>,
1926) -> rustc_hash::FxHashSet<Span> {
1927 let mut spans = rustc_hash::FxHashSet::default();
1928 if candidates.is_empty() {
1929 return spans;
1930 }
1931 let scoping = semantic.scoping();
1932 for import in imports {
1933 let Some(symbol) = crate::binding_references::admitted_import_symbol(semantic, import)
1934 else {
1935 continue;
1936 };
1937 spans.extend(
1938 scoping
1939 .get_resolved_references(symbol)
1940 .filter_map(|reference| {
1941 if !reference.is_value() {
1942 return None;
1943 }
1944 let AstKind::IdentifierReference(identifier) =
1945 semantic.nodes().kind(reference.node_id())
1946 else {
1947 return None;
1948 };
1949 candidates
1950 .contains(&identifier.span)
1951 .then_some(identifier.span)
1952 }),
1953 );
1954 }
1955 spans
1956}
1957
1958#[derive(Default)]
1960struct ImportEqualsClassification {
1961 unreferenced: Vec<String>,
1963}
1964
1965fn binding_reference_usage(
1970 scoping: &oxc_semantic::Scoping,
1971 local_name: &str,
1972) -> Option<(bool, bool, bool)> {
1973 let mut found_binding = false;
1974 let mut has_references = false;
1975 let mut has_type_references = false;
1976 let mut has_value_references = false;
1977 for symbol_id in scoping
1978 .symbol_ids()
1979 .filter(|symbol_id| scoping.symbol_name(*symbol_id) == local_name)
1980 {
1981 found_binding = true;
1982 for reference in scoping.get_resolved_references(symbol_id) {
1983 has_references = true;
1984 has_type_references |= reference.is_type();
1985 has_value_references |= reference.is_value();
1986 }
1987 }
1988 if found_binding
1989 && let Some(reference_ids) = scoping.root_unresolved_references().get(local_name)
1990 {
1991 for reference_id in reference_ids {
1992 let reference = scoping.get_reference(*reference_id);
1993 has_references = true;
1994 has_type_references |= reference.is_type();
1995 has_value_references |= reference.is_value();
1996 }
1997 }
1998 found_binding.then_some((has_references, has_type_references, has_value_references))
1999}
2000
2001fn classify_import_equals_bindings(
2017 scoping: &oxc_semantic::Scoping,
2018 import_equals_bindings: &[String],
2019 template_used: &rustc_hash::FxHashSet<String>,
2020 type_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
2021 value_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
2022) -> ImportEqualsClassification {
2023 if import_equals_bindings.is_empty() {
2024 return ImportEqualsClassification::default();
2025 }
2026
2027 let mut classification = ImportEqualsClassification::default();
2028 for local_name in import_equals_bindings {
2029 if local_name.is_empty() {
2030 continue;
2031 }
2032 let Some((has_references, has_type_references, has_value_references)) =
2033 binding_reference_usage(scoping, local_name)
2034 else {
2035 continue;
2036 };
2037 if !has_references {
2038 if !template_used.contains(local_name) {
2039 classification.unreferenced.push(local_name.clone());
2040 }
2041 continue;
2042 }
2043 if has_type_references {
2044 type_referenced_bindings.insert(local_name.clone());
2045 }
2046 if has_value_references {
2047 value_referenced_bindings.insert(local_name.clone());
2048 }
2049 }
2050 classification
2051}
2052
2053#[derive(Clone, Copy, PartialEq, Eq)]
2054enum MergeDeclarationKind {
2055 Interface,
2056 Class,
2057 Function,
2058 Enum,
2059 Namespace,
2060}
2061
2062fn declaration_merge_facts(
2063 semantic: &oxc_semantic::Semantic<'_>,
2064) -> Vec<fallow_types::extract::DeclarationMergeFact> {
2065 use fallow_types::extract::DeclarationMergeFact;
2066
2067 let scoping = semantic.scoping();
2068 let mut groups = Vec::new();
2069 for symbol_id in scoping.symbol_ids() {
2070 let declarations: Vec<_> = scoping
2071 .symbol_declarations(symbol_id)
2072 .filter_map(|node_id| merge_declaration(semantic.nodes().kind(node_id)))
2073 .collect();
2074 if declarations.len() < 2 {
2075 continue;
2076 }
2077 let mut selected = Vec::new();
2078 for (index, (kind, span)) in declarations.iter().enumerate() {
2079 if declarations
2083 .iter()
2084 .enumerate()
2085 .any(|(other_index, (other, _))| {
2086 other_index != index && compatible_merge(*kind, *other)
2087 })
2088 {
2089 selected.push((span.start, span.end));
2090 }
2091 }
2092 selected.sort_unstable();
2093 selected.dedup();
2094 if selected.len() > 1 {
2095 groups.push(DeclarationMergeFact {
2096 export_spans: selected,
2097 });
2098 }
2099 }
2100 groups.sort_unstable_by_key(|group| group.export_spans[0]);
2101 groups
2102}
2103
2104fn merge_declaration(kind: AstKind<'_>) -> Option<(MergeDeclarationKind, Span)> {
2105 match kind {
2106 AstKind::TSInterfaceDeclaration(declaration) => {
2107 Some((MergeDeclarationKind::Interface, declaration.id.span))
2108 }
2109 AstKind::Class(declaration) => declaration
2110 .id
2111 .as_ref()
2112 .map(|id| (MergeDeclarationKind::Class, id.span)),
2113 AstKind::Function(declaration) => declaration
2114 .id
2115 .as_ref()
2116 .map(|id| (MergeDeclarationKind::Function, id.span)),
2117 AstKind::TSEnumDeclaration(declaration) if !declaration.r#const => {
2118 Some((MergeDeclarationKind::Enum, declaration.id.span))
2119 }
2120 AstKind::TSNamespaceDeclaration(declaration) => {
2121 Some((MergeDeclarationKind::Namespace, declaration.id.span))
2122 }
2123 _ => None,
2124 }
2125}
2126
2127const fn compatible_merge(left: MergeDeclarationKind, right: MergeDeclarationKind) -> bool {
2128 use MergeDeclarationKind::{Class, Enum, Function, Interface, Namespace};
2129
2130 matches!(
2131 (left, right),
2132 (Interface, Interface | Class | Namespace)
2133 | (Class, Interface | Namespace)
2134 | (Function, Namespace)
2135 | (Enum, Enum | Namespace)
2136 | (Namespace, Interface | Class | Function | Enum | Namespace)
2137 )
2138}
2139
2140fn compute_mock_api_reference_spans(
2141 semantic: &oxc_semantic::Semantic<'_>,
2142 imports: &[ImportInfo],
2143 root_scope: oxc_semantic::ScopeId,
2144) -> MockApiReferenceSpans {
2145 let scoping = semantic.scoping();
2146 let mut spans = MockApiReferenceSpans::default();
2147
2148 let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
2149 let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
2150 else {
2151 return;
2152 };
2153 out.extend(
2154 scoping
2155 .get_resolved_references(symbol_id)
2156 .filter_map(|reference| {
2157 let AstKind::IdentifierReference(identifier) =
2158 semantic.nodes().kind(reference.node_id())
2159 else {
2160 return None;
2161 };
2162 Some(identifier.span)
2163 }),
2164 );
2165 };
2166
2167 for import in imports {
2168 if import.is_type_only || import.local_name.is_empty() {
2169 continue;
2170 }
2171 let is_vi_binding = import.source == "vitest"
2172 && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
2173 let is_jest_binding = import.source == "@jest/globals"
2174 && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
2175 let is_vitest_namespace =
2176 import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
2177
2178 if is_vi_binding || is_jest_binding {
2179 collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
2180 } else if is_vitest_namespace {
2181 collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
2182 }
2183 }
2184
2185 for (name, reference_ids) in scoping.root_unresolved_references() {
2193 if name.as_str() != "jest" {
2194 continue;
2195 }
2196 spans
2197 .mock_bindings
2198 .extend(reference_ids.iter().filter_map(|reference_id| {
2199 let reference = scoping.get_reference(*reference_id);
2200 if !reference.is_value() {
2201 return None;
2202 }
2203 let AstKind::IdentifierReference(identifier) =
2204 semantic.nodes().kind(reference.node_id())
2205 else {
2206 return None;
2207 };
2208 Some(identifier.span)
2209 }));
2210 }
2211
2212 spans
2213}
2214
2215fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
2216 use rustc_hash::FxHashSet;
2217
2218 let mut candidates: FxHashSet<String> = FxHashSet::default();
2219 for (name, reference_ids) in scoping.root_unresolved_references() {
2220 if reference_ids
2221 .iter()
2222 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
2223 {
2224 candidates.insert(name.as_str().to_string());
2225 }
2226 }
2227
2228 let mut candidates: Vec<String> = candidates.into_iter().collect();
2229 candidates.sort_unstable();
2230 candidates
2231}
2232
2233pub fn compute_import_binding_usage(
2256 program: &Program<'_>,
2257 imports: &[ImportInfo],
2258 import_equals_bindings: &[String],
2259 template_used: &rustc_hash::FxHashSet<String>,
2260) -> ImportBindingUsage {
2261 let mut semantic_usage = compute_semantic_usage_with_candidates(
2262 program,
2263 imports,
2264 &[],
2265 import_equals_bindings,
2266 template_used,
2267 SemanticReferenceCandidates {
2268 module_bindings: &rustc_hash::FxHashSet::default(),
2269 subclass_bindings: &rustc_hash::FxHashSet::default(),
2270 callback_captures: &rustc_hash::FxHashSet::default(),
2271 imported_calls: &rustc_hash::FxHashSet::default(),
2272 },
2273 );
2274 report_unreferenced_import_equals_bindings(&mut semantic_usage, &[]);
2278 semantic_usage.import_binding_usage
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283 use super::{
2284 advance_jsdoc_brace_stack, classify_jsdoc_visibility_tag, parse_source_to_module,
2285 scan_jsdoc_import_tags_in, scan_jsdoc_imports_in,
2286 };
2287 use fallow_types::discover::FileId;
2288 use fallow_types::extract::{ImportInfo, ImportedName, VisibilityTag};
2289 use std::path::Path;
2290
2291 #[test]
2292 fn classify_jsdoc_visibility_tag_requires_a_bare_tag() {
2293 let cases = [
2294 (" * @public", Some(VisibilityTag::Public)),
2295 (" * @api public", Some(VisibilityTag::Public)),
2296 (" * @publicly", None),
2297 (" * @apipublic", None),
2298 (" * public", None),
2299 (" * @internal", Some(VisibilityTag::Internal)),
2300 (" * @internal-only", Some(VisibilityTag::Internal)),
2301 (" * @internalizer", None),
2302 (" * @internalFoo", None),
2303 (" * internal", None),
2304 (" * @beta", Some(VisibilityTag::Beta)),
2305 (" * @betaware", None),
2306 (" * @beta_x", None),
2307 (" * beta", None),
2308 ("@alpha", Some(VisibilityTag::Alpha)),
2309 ("@alpha Some description", Some(VisibilityTag::Alpha)),
2310 ("@alphabet", None),
2311 (" * alpha", None),
2312 (" * @expected-unused", Some(VisibilityTag::ExpectedUnused)),
2313 (" * @expected-unusedX", None),
2314 ];
2315 for (text, expected) in cases {
2316 let actual = classify_jsdoc_visibility_tag(text).map(|(tag, _)| tag);
2317 assert_eq!(actual, expected, "{text:?}");
2318 }
2319 }
2320
2321 #[test]
2322 fn classify_jsdoc_visibility_tag_keeps_the_expected_unused_reason() {
2323 assert_eq!(
2324 classify_jsdoc_visibility_tag(" * @expected-unused -- kept for the plugin API"),
2325 Some((
2326 VisibilityTag::ExpectedUnused,
2327 Some("kept for the plugin API".to_string())
2328 ))
2329 );
2330 assert_eq!(
2331 classify_jsdoc_visibility_tag(" * @expected-unused"),
2332 Some((VisibilityTag::ExpectedUnused, None))
2333 );
2334 }
2335
2336 fn scan(body: &str) -> Vec<ImportInfo> {
2337 let mut imports = Vec::new();
2338 scan_jsdoc_imports_in(body, &mut imports);
2339 imports
2340 }
2341
2342 #[test]
2343 fn scan_jsdoc_single_import_with_member() {
2344 let imports = scan(" * @param foo {import('./types').Foo}");
2345 assert_eq!(imports.len(), 1);
2346 assert_eq!(imports[0].source, "./types");
2347 assert_eq!(
2348 imports[0].imported_name,
2349 ImportedName::Named("Foo".to_string())
2350 );
2351 assert!(imports[0].is_type_only);
2352 assert!(imports[0].local_name.is_empty());
2353 }
2354
2355 #[test]
2356 fn script_auto_import_candidates_capture_zero_import_value_refs() {
2357 let info = parse_source_to_module(
2358 FileId(0),
2359 Path::new("pages/index.ts"),
2360 r"
2361 useCounter();
2362 const price = formatPrice(10);
2363 const localOnly = () => null;
2364 localOnly();
2365 type Local = UseTypeOnly;
2366 ",
2367 0,
2368 false,
2369 );
2370
2371 assert!(
2372 info.auto_import_candidates
2373 .contains(&"formatPrice".to_string())
2374 );
2375 assert!(
2376 info.auto_import_candidates
2377 .contains(&"useCounter".to_string())
2378 );
2379 assert!(
2380 !info
2381 .auto_import_candidates
2382 .contains(&"UseTypeOnly".to_string())
2383 );
2384 assert!(
2385 !info
2386 .auto_import_candidates
2387 .contains(&"localOnly".to_string())
2388 );
2389 }
2390
2391 #[test]
2392 fn script_auto_import_candidates_skip_explicit_imports() {
2393 let info = parse_source_to_module(
2394 FileId(0),
2395 Path::new("pages/index.ts"),
2396 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
2397 0,
2398 false,
2399 );
2400
2401 assert!(
2402 !info
2403 .auto_import_candidates
2404 .contains(&"useCounter".to_string())
2405 );
2406 assert!(
2407 info.auto_import_candidates
2408 .contains(&"useOther".to_string())
2409 );
2410 }
2411
2412 #[test]
2413 fn scan_jsdoc_double_quoted_path() {
2414 let imports = scan(r#" * @type {import("./types").Foo}"#);
2415 assert_eq!(imports.len(), 1);
2416 assert_eq!(imports[0].source, "./types");
2417 }
2418
2419 #[test]
2420 fn scan_jsdoc_multiple_imports_in_same_body() {
2421 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
2422 assert_eq!(imports.len(), 2);
2423 assert_eq!(imports[0].source, "./a");
2424 assert_eq!(imports[1].source, "./b");
2425 }
2426
2427 #[test]
2428 fn scan_jsdoc_union_annotation_captures_both_members() {
2429 let imports = scan(" * @type {import('./a').A | import('./b').B}");
2430 assert_eq!(imports.len(), 2);
2431 assert_eq!(
2432 imports[0].imported_name,
2433 ImportedName::Named("A".to_string())
2434 );
2435 assert_eq!(
2436 imports[1].imported_name,
2437 ImportedName::Named("B".to_string())
2438 );
2439 }
2440
2441 #[test]
2442 fn scan_jsdoc_nested_member_uses_first_segment() {
2443 let imports = scan(" * @type {import('./types').ns.Foo}");
2444 assert_eq!(imports.len(), 1);
2445 assert_eq!(
2446 imports[0].imported_name,
2447 ImportedName::Named("ns".to_string())
2448 );
2449 }
2450
2451 #[test]
2452 fn scan_jsdoc_parent_relative_path() {
2453 let imports = scan(" * @type {import('../lib/types.js').Foo}");
2454 assert_eq!(imports.len(), 1);
2455 assert_eq!(imports[0].source, "../lib/types.js");
2456 }
2457
2458 #[test]
2459 fn scan_jsdoc_bare_package_specifier() {
2460 let imports = scan(" * @type {import('@scope/pkg').Client}");
2461 assert_eq!(imports.len(), 1);
2462 assert_eq!(imports[0].source, "@scope/pkg");
2463 assert_eq!(
2464 imports[0].imported_name,
2465 ImportedName::Named("Client".to_string())
2466 );
2467 }
2468
2469 fn scan_tags(body: &str) -> Vec<(String, ImportedName)> {
2470 let mut imports = Vec::new();
2471 let mut namespaces = Vec::new();
2472 scan_jsdoc_import_tags_in(body, &mut imports, &mut namespaces);
2473 assert!(namespaces.is_empty());
2474 assert!(imports.iter().all(|import| import.is_type_only));
2475 assert!(imports.iter().all(|import| import.local_name.is_empty()));
2476 imports
2477 .into_iter()
2478 .map(|import| (import.source, import.imported_name))
2479 .collect()
2480 }
2481
2482 fn named(source: &str, name: &str) -> (String, ImportedName) {
2483 (source.to_string(), ImportedName::Named(name.to_string()))
2484 }
2485
2486 #[test]
2487 fn scan_jsdoc_import_tag_named_bindings() {
2488 assert_eq!(
2489 scan_tags(" @import { A, B as C, type D, 'e-f' as E } from '../types/foo' "),
2490 vec![
2491 named("../types/foo", "A"),
2492 named("../types/foo", "B"),
2493 named("../types/foo", "D"),
2494 named("../types/foo", "e-f"),
2495 ]
2496 );
2497 }
2498
2499 #[test]
2500 fn scan_jsdoc_import_tag_namespace_default_and_mixed() {
2501 assert_eq!(
2502 scan_tags(" @import Def from './def' "),
2503 vec![("./def".to_string(), ImportedName::Default)]
2504 );
2505 assert_eq!(
2506 scan_tags(" @import Def, { $A, default as B } from './mixed' "),
2507 vec![
2508 ("./mixed".to_string(), ImportedName::Default),
2509 named("./mixed", "$A"),
2510 ("./mixed".to_string(), ImportedName::Default),
2511 ]
2512 );
2513 assert_eq!(
2514 scan_tags(" @import {} from './empty' "),
2515 vec![("./empty".to_string(), ImportedName::SideEffect)]
2516 );
2517 }
2518
2519 fn jsdoc_tag_imports(source: &str) -> Vec<(String, ImportedName)> {
2520 let info = parse_source_to_module(FileId(0), Path::new("src/index.js"), source, 0, false);
2521 info.imports
2522 .into_iter()
2523 .filter(|import| import.is_type_only && import.local_name.is_empty())
2524 .map(|import| (import.source, import.imported_name))
2525 .collect()
2526 }
2527
2528 #[test]
2529 fn jsdoc_namespace_import_tag_credits_only_the_members_in_types() {
2530 let source = r#"/** @import * as ns from "./ns" */
2531/** @import * as unread from './unread' */
2532/**
2533 * Read ns.Prose in a sentence: no credit.
2534 * @param {ns.Shape | ns.Line} a
2535 * @returns {ns.Shape}
2536 */
2537export function draw(a) { return a; }
2538/** @type {Array<ns.Point>} */
2539export const points = [];
2540"#;
2541 assert_eq!(
2542 jsdoc_tag_imports(source),
2543 vec![
2544 ("./ns".to_string(), ImportedName::SideEffect),
2545 ("./unread".to_string(), ImportedName::SideEffect),
2546 named("./ns", "Shape"),
2547 named("./ns", "Line"),
2548 named("./ns", "Point"),
2549 ]
2550 );
2551 }
2552
2553 #[test]
2554 fn jsdoc_namespace_import_tag_keeps_default_binding() {
2555 assert_eq!(
2556 jsdoc_tag_imports(
2557 "/** @import Def, * as ns from './mod' */
2558/** @type {ns.A} */
2559export const a = 1;
2560"
2561 ),
2562 vec![
2563 ("./mod".to_string(), ImportedName::Default),
2564 ("./mod".to_string(), ImportedName::SideEffect),
2565 named("./mod", "A"),
2566 ]
2567 );
2568 }
2569
2570 #[test]
2571 fn scan_jsdoc_import_tag_spans_lines_and_tags() {
2572 let body = "\n * @import {\n * A,\n * B,\n * } from './multi'\n * @import { C } from './next'\n * @param {A} a\n ";
2573 assert_eq!(
2574 scan_tags(body),
2575 vec![
2576 named("./multi", "A"),
2577 named("./multi", "B"),
2578 named("./next", "C"),
2579 ]
2580 );
2581 }
2582
2583 #[test]
2584 fn scan_jsdoc_import_tag_ignores_non_tags_and_bad_clauses() {
2585 for body in [
2586 " Use @import { A } from './prose' in a sentence ",
2587 " @imports { A } from './plural' ",
2588 " @import { A } './missing-from' ",
2589 " @import { A } from '' ",
2590 " @import { A from './unclosed' ",
2591 " @import { A }\n * @param {A} from './next-tag'",
2592 " @import { A } from './truncated",
2593 " @import",
2594 " @import * from './no-alias' ",
2595 ] {
2596 assert!(scan_tags(body).is_empty(), "{body:?}");
2597 }
2598 }
2599
2600 #[test]
2601 fn scan_jsdoc_without_member_is_side_effect() {
2602 let imports = scan(" * @type {import('./types')}");
2603 assert_eq!(imports.len(), 1);
2604 assert_eq!(imports[0].source, "./types");
2605 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
2606 assert!(imports[0].is_type_only);
2607 }
2608
2609 #[test]
2610 fn scan_jsdoc_empty_path_is_skipped() {
2611 let imports = scan(" * @type {import('').Foo}");
2612 assert!(imports.is_empty());
2613 }
2614
2615 #[test]
2616 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
2617 let imports = scan(" * @type {import('./truncated");
2618 assert!(imports.is_empty());
2619 }
2620
2621 #[test]
2622 fn scan_jsdoc_missing_closing_paren_is_skipped() {
2623 let imports = scan(" * @type {import('./types'.Foo}");
2624 assert!(imports.is_empty());
2625 }
2626
2627 #[test]
2628 fn scan_jsdoc_whitespace_between_paren_and_dot() {
2629 let imports = scan(" * @type {import('./types') .Foo}");
2630 assert_eq!(imports.len(), 1);
2631 assert_eq!(imports[0].source, "./types");
2632 assert_eq!(
2633 imports[0].imported_name,
2634 ImportedName::Named("Foo".to_string())
2635 );
2636 }
2637
2638 #[test]
2639 fn scan_jsdoc_whitespace_between_paren_and_quote() {
2640 let imports = scan(" * @type {import( './types').Foo}");
2641 assert_eq!(imports.len(), 1);
2642 assert_eq!(imports[0].source, "./types");
2643 }
2644
2645 #[test]
2646 fn scan_jsdoc_non_quote_after_paren_skipped() {
2647 let imports = scan(" * @type {import(foo).Bar}");
2648 assert!(imports.is_empty());
2649 }
2650
2651 #[test]
2652 fn scan_jsdoc_ignores_prose_with_import_word() {
2653 let imports = scan(" * This is an important note about imports.");
2654 assert!(imports.is_empty());
2655 }
2656
2657 #[test]
2658 fn scan_jsdoc_utf8_path_works() {
2659 let imports = scan(" * @type {import('./héllo').Foo}");
2660 assert_eq!(imports.len(), 1);
2661 assert_eq!(imports[0].source, "./héllo");
2662 }
2663
2664 #[test]
2665 fn scan_jsdoc_empty_body_is_empty() {
2666 assert!(scan("").is_empty());
2667 }
2668
2669 #[test]
2670 fn scan_jsdoc_no_import_in_body_is_empty() {
2671 assert!(scan(" * @param foo The foo parameter").is_empty());
2672 }
2673
2674 #[test]
2680 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
2681 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
2684 let imports = scan(body);
2685 assert!(
2686 imports.is_empty(),
2687 "prose import() should not be matched; got: {:?}",
2688 imports
2689 .iter()
2690 .map(|i| i.source.as_str())
2691 .collect::<Vec<_>>()
2692 );
2693 }
2694
2695 #[test]
2696 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
2697 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
2698 let imports = scan(body);
2699 assert!(
2700 imports.is_empty(),
2701 "object-literal example import() should not be matched; got: {:?}",
2702 imports
2703 .iter()
2704 .map(|i| i.source.as_str())
2705 .collect::<Vec<_>>()
2706 );
2707 }
2708
2709 #[test]
2710 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
2711 let imports = scan(" * Use {import('./prose')} as an example string.");
2712 assert!(imports.is_empty());
2713 }
2714
2715 #[test]
2716 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
2717 let imports = scan("\n * @example\n * { import('./prose') }\n");
2718 assert!(imports.is_empty());
2719 }
2720
2721 #[test]
2725 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
2726 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
2727 let imports = scan(body);
2728 assert_eq!(imports.len(), 1, "got: {imports:?}");
2729 assert_eq!(imports[0].source, "./real");
2730 assert_eq!(
2731 imports[0].imported_name,
2732 ImportedName::Named("Foo".to_string())
2733 );
2734 }
2735
2736 #[test]
2737 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
2738 let body = "\n * @returns {\n * import('./real').Foo\n * }";
2739 let imports = scan(body);
2740 assert_eq!(imports.len(), 1, "got: {imports:?}");
2741 assert_eq!(imports[0].source, "./real");
2742 assert_eq!(
2743 imports[0].imported_name,
2744 ImportedName::Named("Foo".to_string())
2745 );
2746 }
2747
2748 #[test]
2749 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
2750 let body = "\n * @type\n * { import('./real').Foo }\n";
2751 let imports = scan(body);
2752 assert_eq!(imports.len(), 1, "got: {imports:?}");
2753 assert_eq!(imports[0].source, "./real");
2754 assert_eq!(
2755 imports[0].imported_name,
2756 ImportedName::Named("Foo".to_string())
2757 );
2758 }
2759
2760 #[test]
2761 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
2762 let imports = scan(" * @satisfies {import('./real').Foo}");
2763 assert_eq!(imports.len(), 1, "got: {imports:?}");
2764 assert_eq!(imports[0].source, "./real");
2765 assert_eq!(
2766 imports[0].imported_name,
2767 ImportedName::Named("Foo".to_string())
2768 );
2769 }
2770
2771 #[test]
2772 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
2773 let imports = scan(" * @template {import('./real').Foo} T");
2774 assert_eq!(imports.len(), 1, "got: {imports:?}");
2775 assert_eq!(imports[0].source, "./real");
2776 assert_eq!(
2777 imports[0].imported_name,
2778 ImportedName::Named("Foo".to_string())
2779 );
2780 }
2781
2782 #[test]
2783 fn scan_jsdoc_enum_type_tag_is_still_matched() {
2784 let imports = scan(" * @enum {import('./real').Foo}");
2785 assert_eq!(imports.len(), 1, "got: {imports:?}");
2786 assert_eq!(imports[0].source, "./real");
2787 assert_eq!(
2788 imports[0].imported_name,
2789 ImportedName::Named("Foo".to_string())
2790 );
2791 }
2792
2793 #[test]
2794 fn scan_jsdoc_appends_to_existing_imports() {
2795 let mut imports = vec![ImportInfo {
2796 source: "existing".to_string(),
2797 imported_name: ImportedName::Default,
2798 local_name: "existing".to_string(),
2799 is_type_only: false,
2800 is_type_only_star: false,
2801 from_style: false,
2802 span: oxc_span::Span::default(),
2803 source_span: oxc_span::Span::default(),
2804 }];
2805 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
2806 assert_eq!(imports.len(), 2);
2807 assert_eq!(imports[0].source, "existing");
2808 assert_eq!(imports[1].source, "./new");
2809 }
2810
2811 #[test]
2812 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
2813 let imports = scan(" * @type {import('./t').Abc}");
2814 assert_eq!(imports.len(), 1);
2815 assert_eq!(
2816 imports[0].imported_name,
2817 ImportedName::Named("Abc".to_string())
2818 );
2819 }
2820
2821 #[test]
2822 fn scan_jsdoc_empty_member_name_is_skipped() {
2823 let imports = scan(" * @type {import('./x').}");
2824 assert!(imports.is_empty());
2825 }
2826
2827 #[test]
2828 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
2829 use std::fmt::Write as _;
2835 let mut body = String::from("/**\n");
2836 for i in 0..200 {
2837 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
2838 }
2839 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
2842 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
2843 body.push_str(" */\n");
2844
2845 let imports = scan(&body);
2846 assert_eq!(imports.len(), 201, "got: {imports:?}");
2847 for (i, import) in imports.iter().take(200).enumerate() {
2848 assert_eq!(import.source, format!("./m{i}"));
2849 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
2850 assert!(import.is_type_only);
2851 assert!(import.local_name.is_empty());
2852 }
2853 assert_eq!(imports[200].source, "./deep");
2855 assert_eq!(
2856 imports[200].imported_name,
2857 ImportedName::Named("D".to_string())
2858 );
2859 }
2860
2861 #[test]
2862 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
2863 let cases = [
2867 " * @type {import('./a').A} and {plain} then {import('./b').B}",
2868 " * @remarks { import('./skip') } @param x {import('./c').C}",
2869 " * text } stray close { import('./d').D } trailing",
2870 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
2871 ];
2872 for body in cases {
2873 let bytes = body.as_bytes();
2874 let mut cursor = 0;
2875 while let Some(rel) = body[cursor..].find("import(") {
2876 let import_pos = cursor + rel;
2877 let mut fresh = Vec::new();
2879 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
2880 match b {
2881 b'{' => fresh.push(idx),
2882 b'}' => {
2883 fresh.pop();
2884 }
2885 _ => {}
2886 }
2887 }
2888 let mut stack = Vec::new();
2889 let mut scanned = 0;
2890 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
2891 assert_eq!(
2892 stack.last().copied(),
2893 fresh.last().copied(),
2894 "enclosing brace mismatch at {import_pos} in {body:?}"
2895 );
2896 cursor = import_pos + "import(".len();
2897 }
2898 }
2899 }
2900}