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 from_style: false,
947 span: oxc_span::Span::default(),
948 source_span: oxc_span::Span::default(),
949 }
950}
951
952fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
958 let Some(open_brace) = open_brace else {
959 return false;
960 };
961
962 let prefix = line_prefix_before(body, open_brace);
963 if jsdoc_line_prefix_has_type_tag(prefix) {
964 return true;
965 }
966
967 strip_jsdoc_line_prefix(prefix).is_empty()
968 && preceding_jsdoc_line_has_type_tag(body, open_brace)
969 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
970}
971
972fn advance_jsdoc_brace_stack(
982 body: &[u8],
983 stack: &mut Vec<usize>,
984 scanned: &mut usize,
985 up_to: usize,
986) {
987 let up_to = up_to.min(body.len());
988 while *scanned < up_to {
989 match body[*scanned] {
990 b'{' => stack.push(*scanned),
991 b'}' => {
992 stack.pop();
993 }
994 _ => {}
995 }
996 *scanned += 1;
997 }
998}
999
1000fn line_prefix_before(body: &[u8], pos: usize) -> &str {
1001 let start = body[..pos]
1002 .iter()
1003 .rposition(|&b| b == b'\n')
1004 .map_or(0, |idx| idx + 1);
1005 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
1006}
1007
1008fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
1009 let trimmed = prefix.trim_start();
1010 trimmed
1011 .strip_prefix('*')
1012 .map_or(trimmed, |rest| rest.trim_start())
1013}
1014
1015fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
1016 const TYPE_TAGS: [&str; 17] = [
1017 "@arg",
1018 "@argument",
1019 "@augments",
1020 "@callback",
1021 "@enum",
1022 "@extends",
1023 "@implements",
1024 "@param",
1025 "@property",
1026 "@prop",
1027 "@return",
1028 "@returns",
1029 "@satisfies",
1030 "@template",
1031 "@this",
1032 "@type",
1033 "@typedef",
1034 ];
1035
1036 let prefix = strip_jsdoc_line_prefix(prefix);
1037 TYPE_TAGS
1038 .iter()
1039 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
1040}
1041
1042fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
1043 for (idx, _) in text.match_indices(tag) {
1044 let after = idx + tag.len();
1045 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
1046 return true;
1047 }
1048 }
1049 false
1050}
1051
1052fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1053 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1054 return false;
1055 };
1056
1057 let line_start = body[..line_end]
1058 .iter()
1059 .rposition(|&b| b == b'\n')
1060 .map_or(0, |idx| idx + 1);
1061
1062 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1063}
1064
1065fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1066 let mut at_line_start = true;
1067 let mut i = start.min(body.len());
1068 let end = end.min(body.len());
1069 while i < end {
1070 match body[i] {
1071 b'\n' => {
1072 at_line_start = true;
1073 i += 1;
1074 }
1075 b'\r' | b'\t' | b' ' => {
1076 i += 1;
1077 }
1078 b'*' if at_line_start => {
1079 at_line_start = false;
1080 i += 1;
1081 }
1082 _ => return false,
1083 }
1084 }
1085 true
1086}
1087
1088fn has_public_tag(comment_text: &str) -> bool {
1090 for (i, _) in comment_text.match_indices("@public") {
1091 let after = i + "@public".len();
1092 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1093 return true;
1094 }
1095 }
1096 for (i, _) in comment_text.match_indices("@api") {
1097 let after = i + "@api".len();
1098 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1099 let rest = comment_text[after..].trim_start();
1100 if rest.starts_with("public") {
1101 let after_public = "public".len();
1102 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1103 return true;
1104 }
1105 }
1106 }
1107 }
1108 false
1109}
1110
1111#[derive(Debug, Default, PartialEq, Eq)]
1112pub struct ImportBindingUsage {
1113 pub unused: Vec<String>,
1114 pub type_referenced: Vec<String>,
1115 pub value_referenced: Vec<String>,
1116}
1117
1118#[derive(Debug, Default, PartialEq, Eq)]
1127pub struct MockApiReferenceSpans {
1128 pub(crate) mock_bindings: rustc_hash::FxHashSet<Span>,
1129 pub(crate) vitest_namespaces: rustc_hash::FxHashSet<Span>,
1130}
1131
1132#[derive(Debug, Default, PartialEq, Eq)]
1133pub struct SemanticUsage {
1134 pub import_binding_usage: ImportBindingUsage,
1135 pub auto_import_candidates: Vec<String>,
1136 pub declaration_merges: Vec<fallow_types::extract::DeclarationMergeFact>,
1137 pub(crate) mock_api_reference_spans: MockApiReferenceSpans,
1138 pub(crate) module_binding_reference_spans: rustc_hash::FxHashSet<Span>,
1139}
1140
1141pub fn compute_semantic_usage(
1142 program: &Program<'_>,
1143 imports: &[ImportInfo],
1144 template_used: &rustc_hash::FxHashSet<String>,
1145) -> SemanticUsage {
1146 compute_semantic_usage_with_candidates(
1147 program,
1148 imports,
1149 template_used,
1150 &rustc_hash::FxHashSet::default(),
1151 )
1152}
1153
1154pub fn compute_semantic_usage_for_extractor(
1155 program: &Program<'_>,
1156 extractor: &mut ModuleInfoExtractor,
1157 template_used: &rustc_hash::FxHashSet<String>,
1158) -> SemanticUsage {
1159 let computed_enum_key_spans = extractor.computed_enum_key_reference_spans();
1160 let semantic_usage = compute_semantic_usage_with_candidates(
1161 program,
1162 &extractor.imports,
1163 template_used,
1164 &computed_enum_key_spans,
1165 );
1166 extractor.resolve_computed_enum_key_uses(&semantic_usage.module_binding_reference_spans);
1167 semantic_usage
1168}
1169
1170fn compute_semantic_usage_with_candidates(
1171 program: &Program<'_>,
1172 imports: &[ImportInfo],
1173 template_used: &rustc_hash::FxHashSet<String>,
1174 module_binding_candidates: &rustc_hash::FxHashSet<Span>,
1175) -> SemanticUsage {
1176 use oxc_semantic::SemanticBuilder;
1177 use rustc_hash::FxHashSet;
1178
1179 let semantic_ret = SemanticBuilder::new().build(program);
1180 let semantic = semantic_ret.semantic;
1181 let scoping = semantic.scoping();
1182 let root_scope = scoping.root_scope_id();
1183
1184 let mut unused = Vec::new();
1185 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1186 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1187 for import in imports {
1188 if import.local_name.is_empty() {
1189 continue;
1190 }
1191 let name = oxc_str::Ident::from(import.local_name.as_str());
1192 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1193 let mut has_references = false;
1194 let mut has_type_references = false;
1195 let mut has_value_references = false;
1196
1197 for reference in scoping.get_resolved_references(symbol_id) {
1198 has_references = true;
1199 has_type_references |= reference.is_type();
1200 has_value_references |= reference.is_value();
1201 }
1202
1203 if !has_references {
1204 if !template_used.contains(&import.local_name) {
1205 unused.push(import.local_name.clone());
1206 }
1207 continue;
1208 }
1209
1210 if has_type_references {
1211 type_referenced_bindings.insert(import.local_name.clone());
1212 }
1213 if has_value_references {
1214 value_referenced_bindings.insert(import.local_name.clone());
1215 }
1216 }
1217 }
1218
1219 unused.sort_unstable();
1220
1221 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1222 type_referenced_bindings.sort_unstable();
1223
1224 let mut value_referenced_bindings: Vec<String> =
1225 value_referenced_bindings.into_iter().collect();
1226 value_referenced_bindings.sort_unstable();
1227 let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1228 let declaration_merges = declaration_merge_facts(&semantic);
1229 let mut module_binding_reference_spans = FxHashSet::default();
1230 if !module_binding_candidates.is_empty() {
1231 for symbol_id in scoping.symbol_ids() {
1232 if scoping.symbol_scope_id(symbol_id) != root_scope {
1233 continue;
1234 }
1235 module_binding_reference_spans.extend(
1236 scoping
1237 .get_resolved_references(symbol_id)
1238 .filter_map(|reference| {
1239 let AstKind::IdentifierReference(identifier) =
1240 semantic.nodes().kind(reference.node_id())
1241 else {
1242 return None;
1243 };
1244 module_binding_candidates
1245 .contains(&identifier.span)
1246 .then_some(identifier.span)
1247 }),
1248 );
1249 }
1250 }
1251
1252 SemanticUsage {
1253 import_binding_usage: ImportBindingUsage {
1254 unused,
1255 type_referenced: type_referenced_bindings,
1256 value_referenced: value_referenced_bindings,
1257 },
1258 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1259 declaration_merges,
1260 mock_api_reference_spans,
1261 module_binding_reference_spans,
1262 }
1263}
1264
1265#[derive(Clone, Copy, PartialEq, Eq)]
1266enum MergeDeclarationKind {
1267 Interface,
1268 Class,
1269 Function,
1270 Enum,
1271 Namespace,
1272}
1273
1274fn declaration_merge_facts(
1275 semantic: &oxc_semantic::Semantic<'_>,
1276) -> Vec<fallow_types::extract::DeclarationMergeFact> {
1277 use fallow_types::extract::DeclarationMergeFact;
1278
1279 let scoping = semantic.scoping();
1280 let mut groups = Vec::new();
1281 for symbol_id in scoping.symbol_ids() {
1282 let declarations: Vec<_> = scoping
1283 .symbol_declarations(symbol_id)
1284 .filter_map(|node_id| merge_declaration(semantic.nodes().kind(node_id)))
1285 .collect();
1286 if declarations.len() < 2 {
1287 continue;
1288 }
1289 let mut selected = Vec::new();
1290 for (index, (kind, span)) in declarations.iter().enumerate() {
1291 if declarations
1295 .iter()
1296 .enumerate()
1297 .any(|(other_index, (other, _))| {
1298 other_index != index && compatible_merge(*kind, *other)
1299 })
1300 {
1301 selected.push((span.start, span.end));
1302 }
1303 }
1304 selected.sort_unstable();
1305 selected.dedup();
1306 if selected.len() > 1 {
1307 groups.push(DeclarationMergeFact {
1308 export_spans: selected,
1309 });
1310 }
1311 }
1312 groups.sort_unstable_by_key(|group| group.export_spans[0]);
1313 groups
1314}
1315
1316fn merge_declaration(kind: AstKind<'_>) -> Option<(MergeDeclarationKind, Span)> {
1317 match kind {
1318 AstKind::TSInterfaceDeclaration(declaration) => {
1319 Some((MergeDeclarationKind::Interface, declaration.id.span))
1320 }
1321 AstKind::Class(declaration) => declaration
1322 .id
1323 .as_ref()
1324 .map(|id| (MergeDeclarationKind::Class, id.span)),
1325 AstKind::Function(declaration) => declaration
1326 .id
1327 .as_ref()
1328 .map(|id| (MergeDeclarationKind::Function, id.span)),
1329 AstKind::TSEnumDeclaration(declaration) if !declaration.r#const => {
1330 Some((MergeDeclarationKind::Enum, declaration.id.span))
1331 }
1332 AstKind::TSModuleDeclaration(declaration) => match &declaration.id {
1333 oxc_ast::ast::TSModuleDeclarationName::Identifier(id) => {
1334 Some((MergeDeclarationKind::Namespace, id.span))
1335 }
1336 oxc_ast::ast::TSModuleDeclarationName::StringLiteral(_) => None,
1337 },
1338 _ => None,
1339 }
1340}
1341
1342const fn compatible_merge(left: MergeDeclarationKind, right: MergeDeclarationKind) -> bool {
1343 use MergeDeclarationKind::{Class, Enum, Function, Interface, Namespace};
1344
1345 matches!(
1346 (left, right),
1347 (Interface, Interface | Class | Namespace)
1348 | (Class, Interface | Namespace)
1349 | (Function, Namespace)
1350 | (Enum, Enum | Namespace)
1351 | (Namespace, Interface | Class | Function | Enum | Namespace)
1352 )
1353}
1354
1355fn compute_mock_api_reference_spans(
1356 semantic: &oxc_semantic::Semantic<'_>,
1357 imports: &[ImportInfo],
1358 root_scope: oxc_semantic::ScopeId,
1359) -> MockApiReferenceSpans {
1360 let scoping = semantic.scoping();
1361 let mut spans = MockApiReferenceSpans::default();
1362
1363 let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
1364 let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
1365 else {
1366 return;
1367 };
1368 out.extend(
1369 scoping
1370 .get_resolved_references(symbol_id)
1371 .filter_map(|reference| {
1372 let AstKind::IdentifierReference(identifier) =
1373 semantic.nodes().kind(reference.node_id())
1374 else {
1375 return None;
1376 };
1377 Some(identifier.span)
1378 }),
1379 );
1380 };
1381
1382 for import in imports {
1383 if import.is_type_only || import.local_name.is_empty() {
1384 continue;
1385 }
1386 let is_vi_binding = import.source == "vitest"
1387 && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
1388 let is_jest_binding = import.source == "@jest/globals"
1389 && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
1390 let is_vitest_namespace =
1391 import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
1392
1393 if is_vi_binding || is_jest_binding {
1394 collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
1395 } else if is_vitest_namespace {
1396 collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
1397 }
1398 }
1399
1400 for (name, reference_ids) in scoping.root_unresolved_references() {
1408 if name.as_str() != "jest" {
1409 continue;
1410 }
1411 spans
1412 .mock_bindings
1413 .extend(reference_ids.iter().filter_map(|reference_id| {
1414 let reference = scoping.get_reference(*reference_id);
1415 if !reference.is_value() {
1416 return None;
1417 }
1418 let AstKind::IdentifierReference(identifier) =
1419 semantic.nodes().kind(reference.node_id())
1420 else {
1421 return None;
1422 };
1423 Some(identifier.span)
1424 }));
1425 }
1426
1427 spans
1428}
1429
1430fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1431 use rustc_hash::FxHashSet;
1432
1433 let mut candidates: FxHashSet<String> = FxHashSet::default();
1434 for (name, reference_ids) in scoping.root_unresolved_references() {
1435 if reference_ids
1436 .iter()
1437 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1438 {
1439 candidates.insert(name.as_str().to_string());
1440 }
1441 }
1442
1443 let mut candidates: Vec<String> = candidates.into_iter().collect();
1444 candidates.sort_unstable();
1445 candidates
1446}
1447
1448pub fn compute_import_binding_usage(
1465 program: &Program<'_>,
1466 imports: &[ImportInfo],
1467 template_used: &rustc_hash::FxHashSet<String>,
1468) -> ImportBindingUsage {
1469 compute_semantic_usage(program, imports, template_used).import_binding_usage
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474 use super::{
1475 advance_jsdoc_brace_stack, has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag,
1476 parse_source_to_module, scan_jsdoc_imports_in,
1477 };
1478 use fallow_types::discover::FileId;
1479 use fallow_types::extract::{ImportInfo, ImportedName};
1480 use std::path::Path;
1481
1482 #[test]
1483 fn has_public_tag_matches_bare_tag() {
1484 assert!(has_public_tag(" * @public"));
1485 }
1486
1487 #[test]
1488 fn has_public_tag_matches_api_public_variant() {
1489 assert!(has_public_tag(" * @api public"));
1490 }
1491
1492 #[test]
1493 fn has_public_tag_rejects_partial_word() {
1494 assert!(!has_public_tag(" * @publicly"));
1495 }
1496
1497 #[test]
1498 fn has_public_tag_rejects_at_apipublic() {
1499 assert!(!has_public_tag(" * @apipublic"));
1500 }
1501
1502 #[test]
1503 fn has_public_tag_rejects_missing_at() {
1504 assert!(!has_public_tag(" * public"));
1505 }
1506
1507 #[test]
1508 fn has_internal_tag_matches_bare_tag() {
1509 assert!(has_internal_tag(" * @internal"));
1510 }
1511
1512 #[test]
1513 fn has_internal_tag_rejects_partial_word() {
1514 assert!(!has_internal_tag(" * @internalizer"));
1515 }
1516
1517 #[test]
1518 fn has_internal_tag_rejects_missing_at() {
1519 assert!(!has_internal_tag(" * internal"));
1520 }
1521
1522 #[test]
1523 fn has_beta_tag_matches_bare_tag() {
1524 assert!(has_beta_tag(" * @beta"));
1525 }
1526
1527 #[test]
1528 fn has_beta_tag_rejects_partial_word() {
1529 assert!(!has_beta_tag(" * @betaware"));
1530 }
1531
1532 #[test]
1533 fn has_beta_tag_rejects_missing_at() {
1534 assert!(!has_beta_tag(" * beta"));
1535 }
1536
1537 #[test]
1538 fn alpha_tag_standalone() {
1539 assert!(has_alpha_tag("@alpha"));
1540 }
1541
1542 #[test]
1543 fn alpha_tag_with_text() {
1544 assert!(has_alpha_tag("@alpha Some description"));
1545 }
1546
1547 #[test]
1548 fn alpha_tag_not_prefix() {
1549 assert!(!has_alpha_tag("@alphabet"));
1550 }
1551
1552 #[test]
1553 fn has_alpha_tag_rejects_missing_at() {
1554 assert!(!has_alpha_tag(" * alpha"));
1555 }
1556
1557 fn scan(body: &str) -> Vec<ImportInfo> {
1558 let mut imports = Vec::new();
1559 scan_jsdoc_imports_in(body, &mut imports);
1560 imports
1561 }
1562
1563 #[test]
1564 fn scan_jsdoc_single_import_with_member() {
1565 let imports = scan(" * @param foo {import('./types').Foo}");
1566 assert_eq!(imports.len(), 1);
1567 assert_eq!(imports[0].source, "./types");
1568 assert_eq!(
1569 imports[0].imported_name,
1570 ImportedName::Named("Foo".to_string())
1571 );
1572 assert!(imports[0].is_type_only);
1573 assert!(imports[0].local_name.is_empty());
1574 }
1575
1576 #[test]
1577 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1578 let info = parse_source_to_module(
1579 FileId(0),
1580 Path::new("pages/index.ts"),
1581 r"
1582 useCounter();
1583 const price = formatPrice(10);
1584 const localOnly = () => null;
1585 localOnly();
1586 type Local = UseTypeOnly;
1587 ",
1588 0,
1589 false,
1590 );
1591
1592 assert!(
1593 info.auto_import_candidates
1594 .contains(&"formatPrice".to_string())
1595 );
1596 assert!(
1597 info.auto_import_candidates
1598 .contains(&"useCounter".to_string())
1599 );
1600 assert!(
1601 !info
1602 .auto_import_candidates
1603 .contains(&"UseTypeOnly".to_string())
1604 );
1605 assert!(
1606 !info
1607 .auto_import_candidates
1608 .contains(&"localOnly".to_string())
1609 );
1610 }
1611
1612 #[test]
1613 fn script_auto_import_candidates_skip_explicit_imports() {
1614 let info = parse_source_to_module(
1615 FileId(0),
1616 Path::new("pages/index.ts"),
1617 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1618 0,
1619 false,
1620 );
1621
1622 assert!(
1623 !info
1624 .auto_import_candidates
1625 .contains(&"useCounter".to_string())
1626 );
1627 assert!(
1628 info.auto_import_candidates
1629 .contains(&"useOther".to_string())
1630 );
1631 }
1632
1633 #[test]
1634 fn scan_jsdoc_double_quoted_path() {
1635 let imports = scan(r#" * @type {import("./types").Foo}"#);
1636 assert_eq!(imports.len(), 1);
1637 assert_eq!(imports[0].source, "./types");
1638 }
1639
1640 #[test]
1641 fn scan_jsdoc_multiple_imports_in_same_body() {
1642 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1643 assert_eq!(imports.len(), 2);
1644 assert_eq!(imports[0].source, "./a");
1645 assert_eq!(imports[1].source, "./b");
1646 }
1647
1648 #[test]
1649 fn scan_jsdoc_union_annotation_captures_both_members() {
1650 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1651 assert_eq!(imports.len(), 2);
1652 assert_eq!(
1653 imports[0].imported_name,
1654 ImportedName::Named("A".to_string())
1655 );
1656 assert_eq!(
1657 imports[1].imported_name,
1658 ImportedName::Named("B".to_string())
1659 );
1660 }
1661
1662 #[test]
1663 fn scan_jsdoc_nested_member_uses_first_segment() {
1664 let imports = scan(" * @type {import('./types').ns.Foo}");
1665 assert_eq!(imports.len(), 1);
1666 assert_eq!(
1667 imports[0].imported_name,
1668 ImportedName::Named("ns".to_string())
1669 );
1670 }
1671
1672 #[test]
1673 fn scan_jsdoc_parent_relative_path() {
1674 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1675 assert_eq!(imports.len(), 1);
1676 assert_eq!(imports[0].source, "../lib/types.js");
1677 }
1678
1679 #[test]
1680 fn scan_jsdoc_bare_package_specifier() {
1681 let imports = scan(" * @type {import('@scope/pkg').Client}");
1682 assert_eq!(imports.len(), 1);
1683 assert_eq!(imports[0].source, "@scope/pkg");
1684 assert_eq!(
1685 imports[0].imported_name,
1686 ImportedName::Named("Client".to_string())
1687 );
1688 }
1689
1690 #[test]
1691 fn scan_jsdoc_without_member_is_side_effect() {
1692 let imports = scan(" * @type {import('./types')}");
1693 assert_eq!(imports.len(), 1);
1694 assert_eq!(imports[0].source, "./types");
1695 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1696 assert!(imports[0].is_type_only);
1697 }
1698
1699 #[test]
1700 fn scan_jsdoc_empty_path_is_skipped() {
1701 let imports = scan(" * @type {import('').Foo}");
1702 assert!(imports.is_empty());
1703 }
1704
1705 #[test]
1706 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1707 let imports = scan(" * @type {import('./truncated");
1708 assert!(imports.is_empty());
1709 }
1710
1711 #[test]
1712 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1713 let imports = scan(" * @type {import('./types'.Foo}");
1714 assert!(imports.is_empty());
1715 }
1716
1717 #[test]
1718 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1719 let imports = scan(" * @type {import('./types') .Foo}");
1720 assert_eq!(imports.len(), 1);
1721 assert_eq!(imports[0].source, "./types");
1722 assert_eq!(
1723 imports[0].imported_name,
1724 ImportedName::Named("Foo".to_string())
1725 );
1726 }
1727
1728 #[test]
1729 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1730 let imports = scan(" * @type {import( './types').Foo}");
1731 assert_eq!(imports.len(), 1);
1732 assert_eq!(imports[0].source, "./types");
1733 }
1734
1735 #[test]
1736 fn scan_jsdoc_non_quote_after_paren_skipped() {
1737 let imports = scan(" * @type {import(foo).Bar}");
1738 assert!(imports.is_empty());
1739 }
1740
1741 #[test]
1742 fn scan_jsdoc_ignores_prose_with_import_word() {
1743 let imports = scan(" * This is an important note about imports.");
1744 assert!(imports.is_empty());
1745 }
1746
1747 #[test]
1748 fn scan_jsdoc_utf8_path_works() {
1749 let imports = scan(" * @type {import('./héllo').Foo}");
1750 assert_eq!(imports.len(), 1);
1751 assert_eq!(imports[0].source, "./héllo");
1752 }
1753
1754 #[test]
1755 fn scan_jsdoc_empty_body_is_empty() {
1756 assert!(scan("").is_empty());
1757 }
1758
1759 #[test]
1760 fn scan_jsdoc_no_import_in_body_is_empty() {
1761 assert!(scan(" * @param foo The foo parameter").is_empty());
1762 }
1763
1764 #[test]
1770 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1771 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1774 let imports = scan(body);
1775 assert!(
1776 imports.is_empty(),
1777 "prose import() should not be matched; got: {:?}",
1778 imports
1779 .iter()
1780 .map(|i| i.source.as_str())
1781 .collect::<Vec<_>>()
1782 );
1783 }
1784
1785 #[test]
1786 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1787 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1788 let imports = scan(body);
1789 assert!(
1790 imports.is_empty(),
1791 "object-literal example import() should not be matched; got: {:?}",
1792 imports
1793 .iter()
1794 .map(|i| i.source.as_str())
1795 .collect::<Vec<_>>()
1796 );
1797 }
1798
1799 #[test]
1800 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1801 let imports = scan(" * Use {import('./prose')} as an example string.");
1802 assert!(imports.is_empty());
1803 }
1804
1805 #[test]
1806 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1807 let imports = scan("\n * @example\n * { import('./prose') }\n");
1808 assert!(imports.is_empty());
1809 }
1810
1811 #[test]
1815 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1816 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1817 let imports = scan(body);
1818 assert_eq!(imports.len(), 1, "got: {imports:?}");
1819 assert_eq!(imports[0].source, "./real");
1820 assert_eq!(
1821 imports[0].imported_name,
1822 ImportedName::Named("Foo".to_string())
1823 );
1824 }
1825
1826 #[test]
1827 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1828 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1829 let imports = scan(body);
1830 assert_eq!(imports.len(), 1, "got: {imports:?}");
1831 assert_eq!(imports[0].source, "./real");
1832 assert_eq!(
1833 imports[0].imported_name,
1834 ImportedName::Named("Foo".to_string())
1835 );
1836 }
1837
1838 #[test]
1839 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1840 let body = "\n * @type\n * { import('./real').Foo }\n";
1841 let imports = scan(body);
1842 assert_eq!(imports.len(), 1, "got: {imports:?}");
1843 assert_eq!(imports[0].source, "./real");
1844 assert_eq!(
1845 imports[0].imported_name,
1846 ImportedName::Named("Foo".to_string())
1847 );
1848 }
1849
1850 #[test]
1851 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1852 let imports = scan(" * @satisfies {import('./real').Foo}");
1853 assert_eq!(imports.len(), 1, "got: {imports:?}");
1854 assert_eq!(imports[0].source, "./real");
1855 assert_eq!(
1856 imports[0].imported_name,
1857 ImportedName::Named("Foo".to_string())
1858 );
1859 }
1860
1861 #[test]
1862 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1863 let imports = scan(" * @template {import('./real').Foo} T");
1864 assert_eq!(imports.len(), 1, "got: {imports:?}");
1865 assert_eq!(imports[0].source, "./real");
1866 assert_eq!(
1867 imports[0].imported_name,
1868 ImportedName::Named("Foo".to_string())
1869 );
1870 }
1871
1872 #[test]
1873 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1874 let imports = scan(" * @enum {import('./real').Foo}");
1875 assert_eq!(imports.len(), 1, "got: {imports:?}");
1876 assert_eq!(imports[0].source, "./real");
1877 assert_eq!(
1878 imports[0].imported_name,
1879 ImportedName::Named("Foo".to_string())
1880 );
1881 }
1882
1883 #[test]
1884 fn scan_jsdoc_appends_to_existing_imports() {
1885 let mut imports = vec![ImportInfo {
1886 source: "existing".to_string(),
1887 imported_name: ImportedName::Default,
1888 local_name: "existing".to_string(),
1889 is_type_only: false,
1890 from_style: false,
1891 span: oxc_span::Span::default(),
1892 source_span: oxc_span::Span::default(),
1893 }];
1894 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1895 assert_eq!(imports.len(), 2);
1896 assert_eq!(imports[0].source, "existing");
1897 assert_eq!(imports[1].source, "./new");
1898 }
1899
1900 #[test]
1901 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1902 let imports = scan(" * @type {import('./t').Abc}");
1903 assert_eq!(imports.len(), 1);
1904 assert_eq!(
1905 imports[0].imported_name,
1906 ImportedName::Named("Abc".to_string())
1907 );
1908 }
1909
1910 #[test]
1911 fn scan_jsdoc_empty_member_name_is_skipped() {
1912 let imports = scan(" * @type {import('./x').}");
1913 assert!(imports.is_empty());
1914 }
1915
1916 #[test]
1917 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
1918 use std::fmt::Write as _;
1924 let mut body = String::from("/**\n");
1925 for i in 0..200 {
1926 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
1927 }
1928 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
1931 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
1932 body.push_str(" */\n");
1933
1934 let imports = scan(&body);
1935 assert_eq!(imports.len(), 201, "got: {imports:?}");
1936 for (i, import) in imports.iter().take(200).enumerate() {
1937 assert_eq!(import.source, format!("./m{i}"));
1938 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
1939 assert!(import.is_type_only);
1940 assert!(import.local_name.is_empty());
1941 }
1942 assert_eq!(imports[200].source, "./deep");
1944 assert_eq!(
1945 imports[200].imported_name,
1946 ImportedName::Named("D".to_string())
1947 );
1948 }
1949
1950 #[test]
1951 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
1952 let cases = [
1956 " * @type {import('./a').A} and {plain} then {import('./b').B}",
1957 " * @remarks { import('./skip') } @param x {import('./c').C}",
1958 " * text } stray close { import('./d').D } trailing",
1959 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
1960 ];
1961 for body in cases {
1962 let bytes = body.as_bytes();
1963 let mut cursor = 0;
1964 while let Some(rel) = body[cursor..].find("import(") {
1965 let import_pos = cursor + rel;
1966 let mut fresh = Vec::new();
1968 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
1969 match b {
1970 b'{' => fresh.push(idx),
1971 b'}' => {
1972 fresh.pop();
1973 }
1974 _ => {}
1975 }
1976 }
1977 let mut stack = Vec::new();
1978 let mut scanned = 0;
1979 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
1980 assert_eq!(
1981 stack.last().copied(),
1982 fresh.last().copied(),
1983 "enclosing brace mismatch at {import_pos} in {body:?}"
1984 );
1985 cursor = import_pos + "import(".len();
1986 }
1987 }
1988 }
1989}