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