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