1use std::path::Path;
2
3use oxc_allocator::Allocator;
4use oxc_ast::ast::{Comment, Program};
5use oxc_ast_visit::Visit;
6use oxc_parser::Parser;
7use oxc_span::SourceType;
8
9use crate::ExportInfo;
10use crate::ModuleInfo;
11use crate::astro::{is_astro_file, parse_astro_to_module};
12use crate::css::{is_css_file, parse_css_to_module};
13use crate::glimmer::{is_glimmer_file, strip_glimmer_templates};
14use crate::graphql::{is_graphql_file, parse_graphql_to_module};
15use crate::html::{is_html_file, parse_html_to_module_with_complexity};
16use crate::mdx::{is_mdx_file, parse_mdx_to_module};
17use crate::sfc::{is_sfc_file, parse_sfc_to_module};
18use crate::visitor::{ModuleInfoExtractor, RouteLoadHarvestMode};
19use fallow_types::discover::FileId;
20use fallow_types::extract::{FlagUse, FunctionComplexity, ImportInfo, VisibilityTag};
21
22struct JsxRetryParse {
23 extractor: ModuleInfoExtractor,
24 semantic_usage: SemanticUsage,
25 complexity: Vec<FunctionComplexity>,
26 flag_uses: Vec<FlagUse>,
27 parsed_suppressions: crate::suppress::ParsedSuppressions,
28}
29
30fn source_type_for_path(path: &Path) -> SourceType {
31 match path.extension().and_then(|ext| ext.to_str()) {
32 Some("gts") => SourceType::ts(),
33 Some("gjs") => SourceType::mjs(),
34 _ => SourceType::from_path(path).unwrap_or_default(),
35 }
36}
37
38pub fn parse_source_to_module(
45 file_id: FileId,
46 path: &Path,
47 source: &str,
48 content_hash: u64,
49 need_complexity: bool,
50) -> ModuleInfo {
51 let mut module =
52 parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
53 module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
54 module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
55 if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
59 module.load_return_keys = Vec::new();
60 module.has_unharvestable_load = false;
61 }
62 module
63}
64
65fn is_sveltekit_page_load_file(path: &Path) -> bool {
70 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
71 return false;
72 };
73 matches!(
74 name,
75 "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
76 )
77}
78
79fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
80 if is_sveltekit_page_load_file(path) {
81 return RouteLoadHarvestMode::SvelteKitPage;
82 }
83 if is_conventional_route_loader_file(path) {
84 return RouteLoadHarvestMode::ConventionalRoute;
85 }
86 RouteLoadHarvestMode::None
87}
88
89fn is_conventional_route_loader_file(path: &Path) -> bool {
90 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
91 return false;
92 };
93 if name.starts_with('+') {
94 return false;
95 }
96 if !matches!(
97 path.extension().and_then(|ext| ext.to_str()),
98 Some("ts" | "tsx" | "js" | "jsx")
99 ) {
100 return false;
101 }
102 if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
103 && path
104 .parent()
105 .and_then(|parent| parent.file_name())
106 .and_then(|part| part.to_str())
107 .is_some_and(|part| matches!(part, "app" | "src"))
108 {
109 return true;
110 }
111 path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
112}
113
114fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
115 let mut previous = None;
116 for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
117 if previous == Some(app_dir) && part == "routes" {
118 return true;
119 }
120 previous = Some(part);
121 }
122 false
123}
124
125fn parse_source_to_module_inner(
126 file_id: FileId,
127 path: &Path,
128 source: &str,
129 content_hash: u64,
130 need_complexity: bool,
131) -> ModuleInfo {
132 let source = crate::strip_bom(source);
133 if let Some(module) =
134 parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
135 {
136 return module;
137 }
138
139 let stripped_glimmer_source = is_glimmer_file(path)
140 .then(|| strip_glimmer_templates(source))
141 .flatten();
142 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
143 let source_type = source_type_for_path(path);
144 let allocator = Allocator::default();
145 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
146
147 let mut parsed_suppressions =
148 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
149
150 let (mut extractor, mut semantic_usage) =
151 build_primary_extractor(&parser_return.program, path, source, source_type);
152
153 let line_offsets = fallow_types::extract::compute_line_offsets(source);
154
155 let (mut complexity, mut flag_uses) = compute_primary_complexity_and_flags(
156 &parser_return.program,
157 parser_source,
158 &extractor.inline_template_findings,
159 &line_offsets,
160 need_complexity,
161 );
162
163 apply_jsx_retry_or_jsdoc(
164 &JsxRetryOrJsdocInput {
165 path,
166 parser_source,
167 source_type,
168 need_complexity,
169 line_offsets: &line_offsets,
170 comments: &parser_return.program.comments,
171 source,
172 },
173 &mut ParseOutputs {
174 extractor: &mut extractor,
175 semantic_usage: &mut semantic_usage,
176 complexity: &mut complexity,
177 flag_uses: &mut flag_uses,
178 parsed_suppressions: &mut parsed_suppressions,
179 },
180 );
181
182 assemble_module_info(ModuleAssemblyInput {
183 extractor,
184 file_id,
185 content_hash,
186 parsed_suppressions,
187 semantic_usage,
188 line_offsets,
189 complexity,
190 flag_uses,
191 })
192}
193
194struct JsxRetryOrJsdocInput<'a> {
196 path: &'a Path,
197 parser_source: &'a str,
198 source_type: SourceType,
199 need_complexity: bool,
200 line_offsets: &'a [u32],
201 comments: &'a [Comment],
202 source: &'a str,
203}
204
205struct ModuleAssemblyInput {
206 extractor: ModuleInfoExtractor,
207 file_id: FileId,
208 content_hash: u64,
209 parsed_suppressions: crate::suppress::ParsedSuppressions,
210 semantic_usage: SemanticUsage,
211 line_offsets: Vec<u32>,
212 complexity: Vec<FunctionComplexity>,
213 flag_uses: Vec<FlagUse>,
214}
215
216fn build_primary_extractor(
219 program: &Program<'_>,
220 path: &Path,
221 source: &str,
222 source_type: SourceType,
223) -> (ModuleInfoExtractor, SemanticUsage) {
224 let mut extractor = ModuleInfoExtractor::new();
225 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
226 extractor.jsx_capable = source_type.is_jsx();
230 extractor.visit_program(program);
231 extractor.resolve_pending_local_export_specifiers();
232
233 let template_used_imports =
234 collect_glimmer_template_into_extractor(&mut extractor, path, source);
235 let semantic_usage =
236 compute_semantic_usage(program, &extractor.imports, &template_used_imports);
237 (extractor, semantic_usage)
238}
239
240fn compute_primary_complexity_and_flags(
243 program: &Program<'_>,
244 parser_source: &str,
245 inline_template_findings: &[crate::visitor::InlineTemplateFinding],
246 line_offsets: &[u32],
247 need_complexity: bool,
248) -> (Vec<FunctionComplexity>, Vec<FlagUse>) {
249 let mut complexity = if need_complexity {
250 crate::complexity::compute_complexity(program, parser_source, line_offsets)
251 } else {
252 Vec::new()
253 };
254 if need_complexity {
255 append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
256 }
257
258 let flag_uses = crate::flags::extract_flags(
259 program,
260 line_offsets,
261 &[], &[], false, );
265 (complexity, flag_uses)
266}
267
268struct ParseOutputs<'a> {
270 extractor: &'a mut ModuleInfoExtractor,
271 semantic_usage: &'a mut SemanticUsage,
272 complexity: &'a mut Vec<FunctionComplexity>,
273 flag_uses: &'a mut Vec<FlagUse>,
274 parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
275}
276
277fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
281 let retry_input = JsxRetryInput {
282 path: input.path,
283 source: input.source,
284 parser_source: input.parser_source,
285 source_type: input.source_type,
286 total_extracted: outputs.extractor.exports.len()
287 + outputs.extractor.imports.len()
288 + outputs.extractor.re_exports.len(),
289 need_complexity: input.need_complexity,
290 line_offsets: input.line_offsets,
291 };
292 let Some(retry) = parse_with_jsx_retry(&retry_input) else {
293 apply_jsdoc_tags_to_extractor(&mut *outputs.extractor, input.comments, input.source);
294 return;
295 };
296 *outputs.extractor = retry.extractor;
297 *outputs.semantic_usage = retry.semantic_usage;
298 *outputs.complexity = retry.complexity;
299 *outputs.flag_uses = retry.flag_uses;
300 *outputs.parsed_suppressions = retry.parsed_suppressions;
301}
302
303fn apply_jsdoc_tags_to_extractor(
306 extractor: &mut ModuleInfoExtractor,
307 comments: &[Comment],
308 source: &str,
309) {
310 apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source);
311 extract_jsdoc_import_types(&mut extractor.imports, comments, source);
312}
313
314fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
317 let ModuleAssemblyInput {
318 extractor,
319 file_id,
320 content_hash,
321 parsed_suppressions,
322 semantic_usage,
323 line_offsets,
324 complexity,
325 flag_uses,
326 } = input;
327 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
328 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
329 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
330 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
331 info.auto_import_candidates = semantic_usage.auto_import_candidates;
332 info.line_offsets = line_offsets;
333 info.complexity = complexity;
334 info.flag_uses = flag_uses;
335 info
336}
337
338struct JsxRetryInput<'a> {
339 path: &'a Path,
340 source: &'a str,
341 parser_source: &'a str,
342 source_type: SourceType,
343 total_extracted: usize,
344 need_complexity: bool,
345 line_offsets: &'a [u32],
346}
347
348fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
349 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
350 return None;
351 }
352
353 let jsx_type = if input.source_type.is_typescript() {
354 SourceType::tsx()
355 } else {
356 SourceType::jsx()
357 };
358 let allocator = Allocator::default();
359 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
360 let mut extractor = ModuleInfoExtractor::new();
361 extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
362 extractor.jsx_capable = true;
365 extractor.visit_program(&retry_return.program);
366 extractor.resolve_pending_local_export_specifiers();
367 let retry_total =
368 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
369 if retry_total <= input.total_extracted {
370 return None;
371 }
372
373 let template_used_imports =
374 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
375 let semantic_usage = compute_semantic_usage(
376 &retry_return.program,
377 &extractor.imports,
378 &template_used_imports,
379 );
380 let complexity = retry_complexity(
381 input.need_complexity,
382 &retry_return.program,
383 input.parser_source,
384 input.line_offsets,
385 &extractor,
386 );
387 let flag_uses =
388 crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
389 let parsed_suppressions =
390 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
391 apply_jsdoc_visibility_tags(
392 &mut extractor.exports,
393 &retry_return.program.comments,
394 input.source,
395 );
396 extract_jsdoc_import_types(
397 &mut extractor.imports,
398 &retry_return.program.comments,
399 input.source,
400 );
401 Some(JsxRetryParse {
402 extractor,
403 semantic_usage,
404 complexity,
405 flag_uses,
406 parsed_suppressions,
407 })
408}
409
410fn retry_complexity(
411 need_complexity: bool,
412 program: &Program<'_>,
413 parser_source: &str,
414 line_offsets: &[u32],
415 extractor: &ModuleInfoExtractor,
416) -> Vec<FunctionComplexity> {
417 if !need_complexity {
418 return Vec::new();
419 }
420 let mut complexity =
421 crate::complexity::compute_complexity(program, parser_source, line_offsets);
422 append_inline_template_complexity(
423 &mut complexity,
424 &extractor.inline_template_findings,
425 line_offsets,
426 );
427 complexity
428}
429
430fn parse_non_js_source_to_module(
431 file_id: FileId,
432 path: &Path,
433 source: &str,
434 content_hash: u64,
435 need_complexity: bool,
436) -> Option<ModuleInfo> {
437 if is_sfc_file(path) {
438 return Some(parse_sfc_to_module(
439 file_id,
440 path,
441 source,
442 content_hash,
443 need_complexity,
444 ));
445 }
446 if is_astro_file(path) {
447 return Some(parse_astro_to_module(
448 file_id,
449 source,
450 content_hash,
451 need_complexity,
452 ));
453 }
454 if is_mdx_file(path) {
455 return Some(parse_mdx_to_module(file_id, source, content_hash));
456 }
457 if is_css_file(path) {
458 return Some(parse_css_to_module(file_id, path, source, content_hash));
459 }
460 if is_graphql_file(path) {
461 return Some(parse_graphql_to_module(file_id, source, content_hash));
462 }
463 if is_html_file(path) {
464 return Some(parse_html_to_module_with_complexity(
465 file_id,
466 source,
467 content_hash,
468 need_complexity,
469 ));
470 }
471 None
472}
473
474fn collect_glimmer_template_into_extractor(
496 extractor: &mut ModuleInfoExtractor,
497 path: &Path,
498 source: &str,
499) -> rustc_hash::FxHashSet<String> {
500 use rustc_hash::FxHashSet;
501
502 if !is_glimmer_file(path) {
503 return FxHashSet::default();
504 }
505 let template_ranges = crate::glimmer::find_template_ranges(source);
506 if template_ranges.is_empty() {
507 return FxHashSet::default();
508 }
509
510 let imported_bindings: FxHashSet<String> = extractor
511 .imports
512 .iter()
513 .filter(|import| !import.local_name.is_empty())
514 .map(|import| import.local_name.clone())
515 .collect();
516
517 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
518 source,
519 &template_ranges,
520 &imported_bindings,
521 );
522 extractor.member_accesses.extend(usage.member_accesses);
523 usage.used_bindings
524}
525
526fn append_inline_template_complexity(
537 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
538 findings: &[crate::visitor::InlineTemplateFinding],
539 line_offsets: &[u32],
540) {
541 for finding in findings {
542 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
543 &finding.template_source,
544 ) else {
545 continue;
546 };
547 let (line, col) =
548 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
549 fc.line = line;
550 fc.col = col;
551 complexity.push(fc);
552 }
553}
554
555fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
565 if exports.is_empty() || comments.is_empty() {
566 return;
567 }
568
569 let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
570 if tag_offsets.is_empty() {
571 return;
572 }
573 tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
574
575 for export in exports.iter_mut() {
576 apply_visibility_tag_to_export(export, &tag_offsets, source);
577 }
578}
579
580fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
583 if has_public_tag(text) {
584 Some((VisibilityTag::Public, None))
585 } else if has_internal_tag(text) {
586 Some((VisibilityTag::Internal, None))
587 } else if has_alpha_tag(text) {
588 Some((VisibilityTag::Alpha, None))
589 } else if has_beta_tag(text) {
590 Some((VisibilityTag::Beta, None))
591 } else {
592 let (has_expected_unused, reason) = expected_unused_tag(text);
593 has_expected_unused.then_some((VisibilityTag::ExpectedUnused, reason))
594 }
595}
596
597fn collect_jsdoc_tag_offsets(
600 comments: &[Comment],
601 source: &str,
602) -> Vec<(u32, VisibilityTag, Option<String>)> {
603 let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
604 for comment in comments {
605 if !comment.is_jsdoc() {
606 continue;
607 }
608 let content_span = comment.content_span();
609 let start = content_span.start as usize;
610 let end = (content_span.end as usize).min(source.len());
611 if start >= end {
612 continue;
613 }
614 if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
615 tag_offsets.push((comment.attached_to, tag, reason));
616 }
617 }
618 tag_offsets
619}
620
621fn apply_visibility_tag_to_export(
625 export: &mut ExportInfo,
626 tag_offsets: &[(u32, VisibilityTag, Option<String>)],
627 source: &str,
628) {
629 if export.span.start == 0 && export.span.end == 0 {
630 return;
631 }
632
633 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
634 export.visibility = tag_offsets[idx].1;
635 export
636 .expected_unused_reason
637 .clone_from(&tag_offsets[idx].2);
638 return;
639 }
640
641 let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
642 if idx > 0 {
643 let (offset, tag, ref reason) = tag_offsets[idx - 1];
644 let offset = offset as usize;
645 let export_start = export.span.start as usize;
646 if offset < export_start && export_start <= source.len() {
647 let between = &source[offset..export_start];
648 if between.starts_with("export") && !between.contains(';') && !between.contains('}') {
649 export.visibility = tag;
650 export.expected_unused_reason.clone_from(reason);
651 }
652 }
653 }
654}
655
656fn has_internal_tag(comment_text: &str) -> bool {
658 for (i, _) in comment_text.match_indices("@internal") {
659 let after = i + "@internal".len();
660 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
661 return true;
662 }
663 }
664 false
665}
666
667fn has_beta_tag(comment_text: &str) -> bool {
669 for (i, _) in comment_text.match_indices("@beta") {
670 let after = i + "@beta".len();
671 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
672 return true;
673 }
674 }
675 false
676}
677
678fn has_alpha_tag(comment_text: &str) -> bool {
680 for (i, _) in comment_text.match_indices("@alpha") {
681 let after = i + "@alpha".len();
682 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
683 return true;
684 }
685 }
686 false
687}
688
689fn split_jsdoc_reason(rest: &str) -> Option<String> {
690 for (idx, _) in rest.match_indices("--") {
691 let before_ok = idx == 0
692 || rest[..idx]
693 .chars()
694 .next_back()
695 .is_some_and(char::is_whitespace);
696 let after_idx = idx + 2;
697 let after_ok = after_idx == rest.len()
698 || rest[after_idx..]
699 .chars()
700 .next()
701 .is_some_and(char::is_whitespace);
702 if before_ok && after_ok {
703 let reason = rest[after_idx..].trim();
704 return if reason.is_empty() {
705 None
706 } else {
707 Some(reason.to_string())
708 };
709 }
710 }
711
712 None
713}
714
715fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
717 for (i, _) in comment_text.match_indices("@expected-unused") {
718 let after = i + "@expected-unused".len();
719 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
720 return (true, split_jsdoc_reason(&comment_text[after..]));
721 }
722 }
723 (false, None)
724}
725
726const fn is_ident_char(b: u8) -> bool {
728 b.is_ascii_alphanumeric() || b == b'_'
729}
730
731fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
755 if comments.is_empty() {
756 return;
757 }
758
759 for comment in comments {
760 if !comment.is_jsdoc() {
761 continue;
762 }
763 let content_span = comment.content_span();
764 let start = content_span.start as usize;
765 let end = (content_span.end as usize).min(source.len());
766 if start >= end {
767 continue;
768 }
769 scan_jsdoc_imports_in(&source[start..end], imports);
770 }
771}
772
773fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
781 let bytes = body.as_bytes();
782 let mut cursor = 0;
783 let mut brace_stack: Vec<usize> = Vec::new();
790 let mut scanned = 0;
791 while let Some(rel) = body[cursor..].find("import(") {
792 let import_pos = cursor + rel;
793 advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
794 if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
795 cursor = import_pos + "import(".len();
796 continue;
797 }
798 let open = import_pos + "import(".len();
799 match locate_jsdoc_import_path(body, bytes, open) {
800 JsdocImportScan::Stop => break,
801 JsdocImportScan::Skip(next) => {
802 cursor = next;
803 }
804 JsdocImportScan::Found { path, after_paren } => {
805 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
806 }
807 }
808 }
809}
810
811enum JsdocImportScan<'a> {
814 Stop,
816 Skip(usize),
818 Found { path: &'a str, after_paren: usize },
820}
821
822fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
825 if open >= bytes.len() {
826 return JsdocImportScan::Stop;
827 }
828 let mut i = open;
829 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
830 i += 1;
831 }
832 if i >= bytes.len() {
833 return JsdocImportScan::Stop;
834 }
835 let quote = bytes[i];
836 if quote != b'\'' && quote != b'"' {
837 return JsdocImportScan::Skip(open);
838 }
839 let path_start = i + 1;
840 let Some(rel_close) = body[path_start..].find(quote as char) else {
841 return JsdocImportScan::Stop;
842 };
843 let path_end = path_start + rel_close;
844 let path = &body[path_start..path_end];
845 if path.is_empty() {
846 return JsdocImportScan::Skip(path_end + 1);
847 }
848 let mut j = path_end + 1;
849 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
850 j += 1;
851 }
852 if j >= bytes.len() || bytes[j] != b')' {
853 return JsdocImportScan::Skip(path_end + 1);
854 }
855 j += 1;
856 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
857 j += 1;
858 }
859 JsdocImportScan::Found {
860 path,
861 after_paren: j,
862 }
863}
864
865fn resolve_jsdoc_import(
868 body: &str,
869 bytes: &[u8],
870 after_paren: usize,
871 path: &str,
872 imports: &mut Vec<ImportInfo>,
873) -> usize {
874 let mut j = after_paren;
875 if j >= bytes.len() || bytes[j] != b'.' {
876 imports.push(jsdoc_type_import(
877 path,
878 fallow_types::extract::ImportedName::SideEffect,
879 ));
880 return after_paren;
881 }
882 j += 1;
883 let name_start = j;
884 while j < bytes.len() && is_ident_char(bytes[j]) {
885 j += 1;
886 }
887 if name_start == j {
888 return after_paren;
891 }
892 let member = &body[name_start..j];
893 imports.push(jsdoc_type_import(
894 path,
895 fallow_types::extract::ImportedName::Named(member.to_string()),
896 ));
897 j
898}
899
900fn jsdoc_type_import(
903 source: &str,
904 imported_name: fallow_types::extract::ImportedName,
905) -> ImportInfo {
906 ImportInfo {
907 source: source.to_string(),
908 imported_name,
909 local_name: String::new(),
910 is_type_only: true,
911 from_style: false,
912 span: oxc_span::Span::default(),
913 source_span: oxc_span::Span::default(),
914 }
915}
916
917fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
923 let Some(open_brace) = open_brace else {
924 return false;
925 };
926
927 let prefix = line_prefix_before(body, open_brace);
928 if jsdoc_line_prefix_has_type_tag(prefix) {
929 return true;
930 }
931
932 strip_jsdoc_line_prefix(prefix).is_empty()
933 && preceding_jsdoc_line_has_type_tag(body, open_brace)
934 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
935}
936
937fn advance_jsdoc_brace_stack(
947 body: &[u8],
948 stack: &mut Vec<usize>,
949 scanned: &mut usize,
950 up_to: usize,
951) {
952 let up_to = up_to.min(body.len());
953 while *scanned < up_to {
954 match body[*scanned] {
955 b'{' => stack.push(*scanned),
956 b'}' => {
957 stack.pop();
958 }
959 _ => {}
960 }
961 *scanned += 1;
962 }
963}
964
965fn line_prefix_before(body: &[u8], pos: usize) -> &str {
966 let start = body[..pos]
967 .iter()
968 .rposition(|&b| b == b'\n')
969 .map_or(0, |idx| idx + 1);
970 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
971}
972
973fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
974 let trimmed = prefix.trim_start();
975 trimmed
976 .strip_prefix('*')
977 .map_or(trimmed, |rest| rest.trim_start())
978}
979
980fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
981 const TYPE_TAGS: [&str; 17] = [
982 "@arg",
983 "@argument",
984 "@augments",
985 "@callback",
986 "@enum",
987 "@extends",
988 "@implements",
989 "@param",
990 "@property",
991 "@prop",
992 "@return",
993 "@returns",
994 "@satisfies",
995 "@template",
996 "@this",
997 "@type",
998 "@typedef",
999 ];
1000
1001 let prefix = strip_jsdoc_line_prefix(prefix);
1002 TYPE_TAGS
1003 .iter()
1004 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
1005}
1006
1007fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
1008 for (idx, _) in text.match_indices(tag) {
1009 let after = idx + tag.len();
1010 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
1011 return true;
1012 }
1013 }
1014 false
1015}
1016
1017fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1018 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1019 return false;
1020 };
1021
1022 let line_start = body[..line_end]
1023 .iter()
1024 .rposition(|&b| b == b'\n')
1025 .map_or(0, |idx| idx + 1);
1026
1027 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1028}
1029
1030fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1031 let mut at_line_start = true;
1032 let mut i = start.min(body.len());
1033 let end = end.min(body.len());
1034 while i < end {
1035 match body[i] {
1036 b'\n' => {
1037 at_line_start = true;
1038 i += 1;
1039 }
1040 b'\r' | b'\t' | b' ' => {
1041 i += 1;
1042 }
1043 b'*' if at_line_start => {
1044 at_line_start = false;
1045 i += 1;
1046 }
1047 _ => return false,
1048 }
1049 }
1050 true
1051}
1052
1053fn has_public_tag(comment_text: &str) -> bool {
1055 for (i, _) in comment_text.match_indices("@public") {
1056 let after = i + "@public".len();
1057 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1058 return true;
1059 }
1060 }
1061 for (i, _) in comment_text.match_indices("@api") {
1062 let after = i + "@api".len();
1063 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1064 let rest = comment_text[after..].trim_start();
1065 if rest.starts_with("public") {
1066 let after_public = "public".len();
1067 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1068 return true;
1069 }
1070 }
1071 }
1072 }
1073 false
1074}
1075
1076#[derive(Debug, Default, PartialEq, Eq)]
1077pub struct ImportBindingUsage {
1078 pub unused: Vec<String>,
1079 pub type_referenced: Vec<String>,
1080 pub value_referenced: Vec<String>,
1081}
1082
1083#[derive(Debug, Default, PartialEq, Eq)]
1084pub struct SemanticUsage {
1085 pub import_binding_usage: ImportBindingUsage,
1086 pub auto_import_candidates: Vec<String>,
1087}
1088
1089pub fn compute_semantic_usage(
1090 program: &Program<'_>,
1091 imports: &[ImportInfo],
1092 template_used: &rustc_hash::FxHashSet<String>,
1093) -> SemanticUsage {
1094 use oxc_semantic::SemanticBuilder;
1095 use rustc_hash::FxHashSet;
1096
1097 let semantic_ret = SemanticBuilder::new().build(program);
1098 let semantic = semantic_ret.semantic;
1099 let scoping = semantic.scoping();
1100 let root_scope = scoping.root_scope_id();
1101
1102 let mut unused = Vec::new();
1103 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1104 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1105 for import in imports {
1106 if import.local_name.is_empty() {
1107 continue;
1108 }
1109 let name = oxc_str::Ident::from(import.local_name.as_str());
1110 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1111 let mut has_references = false;
1112 let mut has_type_references = false;
1113 let mut has_value_references = false;
1114
1115 for reference in scoping.get_resolved_references(symbol_id) {
1116 has_references = true;
1117 has_type_references |= reference.is_type();
1118 has_value_references |= reference.is_value();
1119 }
1120
1121 if !has_references {
1122 if !template_used.contains(&import.local_name) {
1123 unused.push(import.local_name.clone());
1124 }
1125 continue;
1126 }
1127
1128 if has_type_references {
1129 type_referenced_bindings.insert(import.local_name.clone());
1130 }
1131 if has_value_references {
1132 value_referenced_bindings.insert(import.local_name.clone());
1133 }
1134 }
1135 }
1136
1137 unused.sort_unstable();
1138
1139 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1140 type_referenced_bindings.sort_unstable();
1141
1142 let mut value_referenced_bindings: Vec<String> =
1143 value_referenced_bindings.into_iter().collect();
1144 value_referenced_bindings.sort_unstable();
1145
1146 SemanticUsage {
1147 import_binding_usage: ImportBindingUsage {
1148 unused,
1149 type_referenced: type_referenced_bindings,
1150 value_referenced: value_referenced_bindings,
1151 },
1152 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1153 }
1154}
1155
1156pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
1157 use oxc_semantic::SemanticBuilder;
1158
1159 let semantic_ret = SemanticBuilder::new().build(program);
1160 let semantic = semantic_ret.semantic;
1161 compute_auto_import_candidates_from_semantic(semantic.scoping())
1162}
1163
1164fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1165 use rustc_hash::FxHashSet;
1166
1167 let mut candidates: FxHashSet<String> = FxHashSet::default();
1168 for (name, reference_ids) in scoping.root_unresolved_references() {
1169 if reference_ids
1170 .iter()
1171 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1172 {
1173 candidates.insert(name.as_str().to_string());
1174 }
1175 }
1176
1177 let mut candidates: Vec<String> = candidates.into_iter().collect();
1178 candidates.sort_unstable();
1179 candidates
1180}
1181
1182pub fn compute_import_binding_usage(
1199 program: &Program<'_>,
1200 imports: &[ImportInfo],
1201 template_used: &rustc_hash::FxHashSet<String>,
1202) -> ImportBindingUsage {
1203 compute_semantic_usage(program, imports, template_used).import_binding_usage
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::{
1209 advance_jsdoc_brace_stack, has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag,
1210 parse_source_to_module, scan_jsdoc_imports_in,
1211 };
1212 use fallow_types::discover::FileId;
1213 use fallow_types::extract::{ImportInfo, ImportedName};
1214 use std::path::Path;
1215
1216 #[test]
1217 fn has_public_tag_matches_bare_tag() {
1218 assert!(has_public_tag(" * @public"));
1219 }
1220
1221 #[test]
1222 fn has_public_tag_matches_api_public_variant() {
1223 assert!(has_public_tag(" * @api public"));
1224 }
1225
1226 #[test]
1227 fn has_public_tag_rejects_partial_word() {
1228 assert!(!has_public_tag(" * @publicly"));
1229 }
1230
1231 #[test]
1232 fn has_public_tag_rejects_at_apipublic() {
1233 assert!(!has_public_tag(" * @apipublic"));
1234 }
1235
1236 #[test]
1237 fn has_public_tag_rejects_missing_at() {
1238 assert!(!has_public_tag(" * public"));
1239 }
1240
1241 #[test]
1242 fn has_internal_tag_matches_bare_tag() {
1243 assert!(has_internal_tag(" * @internal"));
1244 }
1245
1246 #[test]
1247 fn has_internal_tag_rejects_partial_word() {
1248 assert!(!has_internal_tag(" * @internalizer"));
1249 }
1250
1251 #[test]
1252 fn has_internal_tag_rejects_missing_at() {
1253 assert!(!has_internal_tag(" * internal"));
1254 }
1255
1256 #[test]
1257 fn has_beta_tag_matches_bare_tag() {
1258 assert!(has_beta_tag(" * @beta"));
1259 }
1260
1261 #[test]
1262 fn has_beta_tag_rejects_partial_word() {
1263 assert!(!has_beta_tag(" * @betaware"));
1264 }
1265
1266 #[test]
1267 fn has_beta_tag_rejects_missing_at() {
1268 assert!(!has_beta_tag(" * beta"));
1269 }
1270
1271 #[test]
1272 fn alpha_tag_standalone() {
1273 assert!(has_alpha_tag("@alpha"));
1274 }
1275
1276 #[test]
1277 fn alpha_tag_with_text() {
1278 assert!(has_alpha_tag("@alpha Some description"));
1279 }
1280
1281 #[test]
1282 fn alpha_tag_not_prefix() {
1283 assert!(!has_alpha_tag("@alphabet"));
1284 }
1285
1286 #[test]
1287 fn has_alpha_tag_rejects_missing_at() {
1288 assert!(!has_alpha_tag(" * alpha"));
1289 }
1290
1291 fn scan(body: &str) -> Vec<ImportInfo> {
1292 let mut imports = Vec::new();
1293 scan_jsdoc_imports_in(body, &mut imports);
1294 imports
1295 }
1296
1297 #[test]
1298 fn scan_jsdoc_single_import_with_member() {
1299 let imports = scan(" * @param foo {import('./types').Foo}");
1300 assert_eq!(imports.len(), 1);
1301 assert_eq!(imports[0].source, "./types");
1302 assert_eq!(
1303 imports[0].imported_name,
1304 ImportedName::Named("Foo".to_string())
1305 );
1306 assert!(imports[0].is_type_only);
1307 assert!(imports[0].local_name.is_empty());
1308 }
1309
1310 #[test]
1311 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1312 let info = parse_source_to_module(
1313 FileId(0),
1314 Path::new("pages/index.ts"),
1315 r"
1316 useCounter();
1317 const price = formatPrice(10);
1318 const localOnly = () => null;
1319 localOnly();
1320 type Local = UseTypeOnly;
1321 ",
1322 0,
1323 false,
1324 );
1325
1326 assert!(
1327 info.auto_import_candidates
1328 .contains(&"formatPrice".to_string())
1329 );
1330 assert!(
1331 info.auto_import_candidates
1332 .contains(&"useCounter".to_string())
1333 );
1334 assert!(
1335 !info
1336 .auto_import_candidates
1337 .contains(&"UseTypeOnly".to_string())
1338 );
1339 assert!(
1340 !info
1341 .auto_import_candidates
1342 .contains(&"localOnly".to_string())
1343 );
1344 }
1345
1346 #[test]
1347 fn script_auto_import_candidates_skip_explicit_imports() {
1348 let info = parse_source_to_module(
1349 FileId(0),
1350 Path::new("pages/index.ts"),
1351 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1352 0,
1353 false,
1354 );
1355
1356 assert!(
1357 !info
1358 .auto_import_candidates
1359 .contains(&"useCounter".to_string())
1360 );
1361 assert!(
1362 info.auto_import_candidates
1363 .contains(&"useOther".to_string())
1364 );
1365 }
1366
1367 #[test]
1368 fn scan_jsdoc_double_quoted_path() {
1369 let imports = scan(r#" * @type {import("./types").Foo}"#);
1370 assert_eq!(imports.len(), 1);
1371 assert_eq!(imports[0].source, "./types");
1372 }
1373
1374 #[test]
1375 fn scan_jsdoc_multiple_imports_in_same_body() {
1376 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1377 assert_eq!(imports.len(), 2);
1378 assert_eq!(imports[0].source, "./a");
1379 assert_eq!(imports[1].source, "./b");
1380 }
1381
1382 #[test]
1383 fn scan_jsdoc_union_annotation_captures_both_members() {
1384 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1385 assert_eq!(imports.len(), 2);
1386 assert_eq!(
1387 imports[0].imported_name,
1388 ImportedName::Named("A".to_string())
1389 );
1390 assert_eq!(
1391 imports[1].imported_name,
1392 ImportedName::Named("B".to_string())
1393 );
1394 }
1395
1396 #[test]
1397 fn scan_jsdoc_nested_member_uses_first_segment() {
1398 let imports = scan(" * @type {import('./types').ns.Foo}");
1399 assert_eq!(imports.len(), 1);
1400 assert_eq!(
1401 imports[0].imported_name,
1402 ImportedName::Named("ns".to_string())
1403 );
1404 }
1405
1406 #[test]
1407 fn scan_jsdoc_parent_relative_path() {
1408 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1409 assert_eq!(imports.len(), 1);
1410 assert_eq!(imports[0].source, "../lib/types.js");
1411 }
1412
1413 #[test]
1414 fn scan_jsdoc_bare_package_specifier() {
1415 let imports = scan(" * @type {import('@scope/pkg').Client}");
1416 assert_eq!(imports.len(), 1);
1417 assert_eq!(imports[0].source, "@scope/pkg");
1418 assert_eq!(
1419 imports[0].imported_name,
1420 ImportedName::Named("Client".to_string())
1421 );
1422 }
1423
1424 #[test]
1425 fn scan_jsdoc_without_member_is_side_effect() {
1426 let imports = scan(" * @type {import('./types')}");
1427 assert_eq!(imports.len(), 1);
1428 assert_eq!(imports[0].source, "./types");
1429 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1430 assert!(imports[0].is_type_only);
1431 }
1432
1433 #[test]
1434 fn scan_jsdoc_empty_path_is_skipped() {
1435 let imports = scan(" * @type {import('').Foo}");
1436 assert!(imports.is_empty());
1437 }
1438
1439 #[test]
1440 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1441 let imports = scan(" * @type {import('./truncated");
1442 assert!(imports.is_empty());
1443 }
1444
1445 #[test]
1446 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1447 let imports = scan(" * @type {import('./types'.Foo}");
1448 assert!(imports.is_empty());
1449 }
1450
1451 #[test]
1452 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1453 let imports = scan(" * @type {import('./types') .Foo}");
1454 assert_eq!(imports.len(), 1);
1455 assert_eq!(imports[0].source, "./types");
1456 assert_eq!(
1457 imports[0].imported_name,
1458 ImportedName::Named("Foo".to_string())
1459 );
1460 }
1461
1462 #[test]
1463 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1464 let imports = scan(" * @type {import( './types').Foo}");
1465 assert_eq!(imports.len(), 1);
1466 assert_eq!(imports[0].source, "./types");
1467 }
1468
1469 #[test]
1470 fn scan_jsdoc_non_quote_after_paren_skipped() {
1471 let imports = scan(" * @type {import(foo).Bar}");
1472 assert!(imports.is_empty());
1473 }
1474
1475 #[test]
1476 fn scan_jsdoc_ignores_prose_with_import_word() {
1477 let imports = scan(" * This is an important note about imports.");
1478 assert!(imports.is_empty());
1479 }
1480
1481 #[test]
1482 fn scan_jsdoc_utf8_path_works() {
1483 let imports = scan(" * @type {import('./héllo').Foo}");
1484 assert_eq!(imports.len(), 1);
1485 assert_eq!(imports[0].source, "./héllo");
1486 }
1487
1488 #[test]
1489 fn scan_jsdoc_empty_body_is_empty() {
1490 assert!(scan("").is_empty());
1491 }
1492
1493 #[test]
1494 fn scan_jsdoc_no_import_in_body_is_empty() {
1495 assert!(scan(" * @param foo The foo parameter").is_empty());
1496 }
1497
1498 #[test]
1504 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1505 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1508 let imports = scan(body);
1509 assert!(
1510 imports.is_empty(),
1511 "prose import() should not be matched; got: {:?}",
1512 imports
1513 .iter()
1514 .map(|i| i.source.as_str())
1515 .collect::<Vec<_>>()
1516 );
1517 }
1518
1519 #[test]
1520 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1521 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1522 let imports = scan(body);
1523 assert!(
1524 imports.is_empty(),
1525 "object-literal example import() should not be matched; got: {:?}",
1526 imports
1527 .iter()
1528 .map(|i| i.source.as_str())
1529 .collect::<Vec<_>>()
1530 );
1531 }
1532
1533 #[test]
1534 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1535 let imports = scan(" * Use {import('./prose')} as an example string.");
1536 assert!(imports.is_empty());
1537 }
1538
1539 #[test]
1540 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1541 let imports = scan("\n * @example\n * { import('./prose') }\n");
1542 assert!(imports.is_empty());
1543 }
1544
1545 #[test]
1549 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1550 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1551 let imports = scan(body);
1552 assert_eq!(imports.len(), 1, "got: {imports:?}");
1553 assert_eq!(imports[0].source, "./real");
1554 assert_eq!(
1555 imports[0].imported_name,
1556 ImportedName::Named("Foo".to_string())
1557 );
1558 }
1559
1560 #[test]
1561 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1562 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1563 let imports = scan(body);
1564 assert_eq!(imports.len(), 1, "got: {imports:?}");
1565 assert_eq!(imports[0].source, "./real");
1566 assert_eq!(
1567 imports[0].imported_name,
1568 ImportedName::Named("Foo".to_string())
1569 );
1570 }
1571
1572 #[test]
1573 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1574 let body = "\n * @type\n * { import('./real').Foo }\n";
1575 let imports = scan(body);
1576 assert_eq!(imports.len(), 1, "got: {imports:?}");
1577 assert_eq!(imports[0].source, "./real");
1578 assert_eq!(
1579 imports[0].imported_name,
1580 ImportedName::Named("Foo".to_string())
1581 );
1582 }
1583
1584 #[test]
1585 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1586 let imports = scan(" * @satisfies {import('./real').Foo}");
1587 assert_eq!(imports.len(), 1, "got: {imports:?}");
1588 assert_eq!(imports[0].source, "./real");
1589 assert_eq!(
1590 imports[0].imported_name,
1591 ImportedName::Named("Foo".to_string())
1592 );
1593 }
1594
1595 #[test]
1596 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1597 let imports = scan(" * @template {import('./real').Foo} T");
1598 assert_eq!(imports.len(), 1, "got: {imports:?}");
1599 assert_eq!(imports[0].source, "./real");
1600 assert_eq!(
1601 imports[0].imported_name,
1602 ImportedName::Named("Foo".to_string())
1603 );
1604 }
1605
1606 #[test]
1607 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1608 let imports = scan(" * @enum {import('./real').Foo}");
1609 assert_eq!(imports.len(), 1, "got: {imports:?}");
1610 assert_eq!(imports[0].source, "./real");
1611 assert_eq!(
1612 imports[0].imported_name,
1613 ImportedName::Named("Foo".to_string())
1614 );
1615 }
1616
1617 #[test]
1618 fn scan_jsdoc_appends_to_existing_imports() {
1619 let mut imports = vec![ImportInfo {
1620 source: "existing".to_string(),
1621 imported_name: ImportedName::Default,
1622 local_name: "existing".to_string(),
1623 is_type_only: false,
1624 from_style: false,
1625 span: oxc_span::Span::default(),
1626 source_span: oxc_span::Span::default(),
1627 }];
1628 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1629 assert_eq!(imports.len(), 2);
1630 assert_eq!(imports[0].source, "existing");
1631 assert_eq!(imports[1].source, "./new");
1632 }
1633
1634 #[test]
1635 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1636 let imports = scan(" * @type {import('./t').Abc}");
1637 assert_eq!(imports.len(), 1);
1638 assert_eq!(
1639 imports[0].imported_name,
1640 ImportedName::Named("Abc".to_string())
1641 );
1642 }
1643
1644 #[test]
1645 fn scan_jsdoc_empty_member_name_is_skipped() {
1646 let imports = scan(" * @type {import('./x').}");
1647 assert!(imports.is_empty());
1648 }
1649
1650 #[test]
1651 fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
1652 use std::fmt::Write as _;
1658 let mut body = String::from("/**\n");
1659 for i in 0..200 {
1660 let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
1661 }
1662 body.push_str(" * @remarks import('./ignored') appears in prose here\n");
1665 body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
1666 body.push_str(" */\n");
1667
1668 let imports = scan(&body);
1669 assert_eq!(imports.len(), 201, "got: {imports:?}");
1670 for (i, import) in imports.iter().take(200).enumerate() {
1671 assert_eq!(import.source, format!("./m{i}"));
1672 assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
1673 assert!(import.is_type_only);
1674 assert!(import.local_name.is_empty());
1675 }
1676 assert_eq!(imports[200].source, "./deep");
1678 assert_eq!(
1679 imports[200].imported_name,
1680 ImportedName::Named("D".to_string())
1681 );
1682 }
1683
1684 #[test]
1685 fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
1686 let cases = [
1690 " * @type {import('./a').A} and {plain} then {import('./b').B}",
1691 " * @remarks { import('./skip') } @param x {import('./c').C}",
1692 " * text } stray close { import('./d').D } trailing",
1693 " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
1694 ];
1695 for body in cases {
1696 let bytes = body.as_bytes();
1697 let mut cursor = 0;
1698 while let Some(rel) = body[cursor..].find("import(") {
1699 let import_pos = cursor + rel;
1700 let mut fresh = Vec::new();
1702 for (idx, &b) in bytes[..import_pos].iter().enumerate() {
1703 match b {
1704 b'{' => fresh.push(idx),
1705 b'}' => {
1706 fresh.pop();
1707 }
1708 _ => {}
1709 }
1710 }
1711 let mut stack = Vec::new();
1712 let mut scanned = 0;
1713 advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
1714 assert_eq!(
1715 stack.last().copied(),
1716 fresh.last().copied(),
1717 "enclosing brace mismatch at {import_pos} in {body:?}"
1718 );
1719 cursor = import_pos + "import(".len();
1720 }
1721 }
1722 }
1723}