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 while let Some(rel) = body[cursor..].find("import(") {
784 let import_pos = cursor + rel;
785 if !is_inside_jsdoc_type_brace_group(bytes, import_pos) {
786 cursor = import_pos + "import(".len();
787 continue;
788 }
789 let open = import_pos + "import(".len();
790 match locate_jsdoc_import_path(body, bytes, open) {
791 JsdocImportScan::Stop => break,
792 JsdocImportScan::Skip(next) => {
793 cursor = next;
794 }
795 JsdocImportScan::Found { path, after_paren } => {
796 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
797 }
798 }
799 }
800}
801
802enum JsdocImportScan<'a> {
805 Stop,
807 Skip(usize),
809 Found { path: &'a str, after_paren: usize },
811}
812
813fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
816 if open >= bytes.len() {
817 return JsdocImportScan::Stop;
818 }
819 let mut i = open;
820 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
821 i += 1;
822 }
823 if i >= bytes.len() {
824 return JsdocImportScan::Stop;
825 }
826 let quote = bytes[i];
827 if quote != b'\'' && quote != b'"' {
828 return JsdocImportScan::Skip(open);
829 }
830 let path_start = i + 1;
831 let Some(rel_close) = body[path_start..].find(quote as char) else {
832 return JsdocImportScan::Stop;
833 };
834 let path_end = path_start + rel_close;
835 let path = &body[path_start..path_end];
836 if path.is_empty() {
837 return JsdocImportScan::Skip(path_end + 1);
838 }
839 let mut j = path_end + 1;
840 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
841 j += 1;
842 }
843 if j >= bytes.len() || bytes[j] != b')' {
844 return JsdocImportScan::Skip(path_end + 1);
845 }
846 j += 1;
847 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
848 j += 1;
849 }
850 JsdocImportScan::Found {
851 path,
852 after_paren: j,
853 }
854}
855
856fn resolve_jsdoc_import(
859 body: &str,
860 bytes: &[u8],
861 after_paren: usize,
862 path: &str,
863 imports: &mut Vec<ImportInfo>,
864) -> usize {
865 let mut j = after_paren;
866 if j >= bytes.len() || bytes[j] != b'.' {
867 imports.push(jsdoc_type_import(
868 path,
869 fallow_types::extract::ImportedName::SideEffect,
870 ));
871 return after_paren;
872 }
873 j += 1;
874 let name_start = j;
875 while j < bytes.len() && is_ident_char(bytes[j]) {
876 j += 1;
877 }
878 if name_start == j {
879 return after_paren;
882 }
883 let member = &body[name_start..j];
884 imports.push(jsdoc_type_import(
885 path,
886 fallow_types::extract::ImportedName::Named(member.to_string()),
887 ));
888 j
889}
890
891fn jsdoc_type_import(
894 source: &str,
895 imported_name: fallow_types::extract::ImportedName,
896) -> ImportInfo {
897 ImportInfo {
898 source: source.to_string(),
899 imported_name,
900 local_name: String::new(),
901 is_type_only: true,
902 from_style: false,
903 span: oxc_span::Span::default(),
904 source_span: oxc_span::Span::default(),
905 }
906}
907
908fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize) -> bool {
912 let Some(open_brace) = enclosing_jsdoc_brace_start(body, pos) else {
913 return false;
914 };
915
916 let prefix = line_prefix_before(body, open_brace);
917 if jsdoc_line_prefix_has_type_tag(prefix) {
918 return true;
919 }
920
921 strip_jsdoc_line_prefix(prefix).is_empty()
922 && preceding_jsdoc_line_has_type_tag(body, open_brace)
923 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
924}
925
926fn enclosing_jsdoc_brace_start(body: &[u8], pos: usize) -> Option<usize> {
927 let mut stack = Vec::new();
928 let limit = pos.min(body.len());
929 for (idx, &b) in body[..limit].iter().enumerate() {
930 match b {
931 b'{' => stack.push(idx),
932 b'}' => {
933 stack.pop();
934 }
935 _ => {}
936 }
937 }
938 stack.pop()
939}
940
941fn line_prefix_before(body: &[u8], pos: usize) -> &str {
942 let start = body[..pos]
943 .iter()
944 .rposition(|&b| b == b'\n')
945 .map_or(0, |idx| idx + 1);
946 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
947}
948
949fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
950 let trimmed = prefix.trim_start();
951 trimmed
952 .strip_prefix('*')
953 .map_or(trimmed, |rest| rest.trim_start())
954}
955
956fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
957 const TYPE_TAGS: [&str; 17] = [
958 "@arg",
959 "@argument",
960 "@augments",
961 "@callback",
962 "@enum",
963 "@extends",
964 "@implements",
965 "@param",
966 "@property",
967 "@prop",
968 "@return",
969 "@returns",
970 "@satisfies",
971 "@template",
972 "@this",
973 "@type",
974 "@typedef",
975 ];
976
977 let prefix = strip_jsdoc_line_prefix(prefix);
978 TYPE_TAGS
979 .iter()
980 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
981}
982
983fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
984 for (idx, _) in text.match_indices(tag) {
985 let after = idx + tag.len();
986 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
987 return true;
988 }
989 }
990 false
991}
992
993fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
994 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
995 return false;
996 };
997
998 let line_start = body[..line_end]
999 .iter()
1000 .rposition(|&b| b == b'\n')
1001 .map_or(0, |idx| idx + 1);
1002
1003 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1004}
1005
1006fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1007 let mut at_line_start = true;
1008 let mut i = start.min(body.len());
1009 let end = end.min(body.len());
1010 while i < end {
1011 match body[i] {
1012 b'\n' => {
1013 at_line_start = true;
1014 i += 1;
1015 }
1016 b'\r' | b'\t' | b' ' => {
1017 i += 1;
1018 }
1019 b'*' if at_line_start => {
1020 at_line_start = false;
1021 i += 1;
1022 }
1023 _ => return false,
1024 }
1025 }
1026 true
1027}
1028
1029fn has_public_tag(comment_text: &str) -> bool {
1031 for (i, _) in comment_text.match_indices("@public") {
1032 let after = i + "@public".len();
1033 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1034 return true;
1035 }
1036 }
1037 for (i, _) in comment_text.match_indices("@api") {
1038 let after = i + "@api".len();
1039 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1040 let rest = comment_text[after..].trim_start();
1041 if rest.starts_with("public") {
1042 let after_public = "public".len();
1043 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1044 return true;
1045 }
1046 }
1047 }
1048 }
1049 false
1050}
1051
1052#[derive(Debug, Default, PartialEq, Eq)]
1053pub struct ImportBindingUsage {
1054 pub unused: Vec<String>,
1055 pub type_referenced: Vec<String>,
1056 pub value_referenced: Vec<String>,
1057}
1058
1059#[derive(Debug, Default, PartialEq, Eq)]
1060pub struct SemanticUsage {
1061 pub import_binding_usage: ImportBindingUsage,
1062 pub auto_import_candidates: Vec<String>,
1063}
1064
1065pub fn compute_semantic_usage(
1066 program: &Program<'_>,
1067 imports: &[ImportInfo],
1068 template_used: &rustc_hash::FxHashSet<String>,
1069) -> SemanticUsage {
1070 use oxc_semantic::SemanticBuilder;
1071 use rustc_hash::FxHashSet;
1072
1073 let semantic_ret = SemanticBuilder::new().build(program);
1074 let semantic = semantic_ret.semantic;
1075 let scoping = semantic.scoping();
1076 let root_scope = scoping.root_scope_id();
1077
1078 let mut unused = Vec::new();
1079 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1080 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1081 for import in imports {
1082 if import.local_name.is_empty() {
1083 continue;
1084 }
1085 let name = oxc_str::Ident::from(import.local_name.as_str());
1086 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1087 let mut has_references = false;
1088 let mut has_type_references = false;
1089 let mut has_value_references = false;
1090
1091 for reference in scoping.get_resolved_references(symbol_id) {
1092 has_references = true;
1093 has_type_references |= reference.is_type();
1094 has_value_references |= reference.is_value();
1095 }
1096
1097 if !has_references {
1098 if !template_used.contains(&import.local_name) {
1099 unused.push(import.local_name.clone());
1100 }
1101 continue;
1102 }
1103
1104 if has_type_references {
1105 type_referenced_bindings.insert(import.local_name.clone());
1106 }
1107 if has_value_references {
1108 value_referenced_bindings.insert(import.local_name.clone());
1109 }
1110 }
1111 }
1112
1113 unused.sort_unstable();
1114
1115 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1116 type_referenced_bindings.sort_unstable();
1117
1118 let mut value_referenced_bindings: Vec<String> =
1119 value_referenced_bindings.into_iter().collect();
1120 value_referenced_bindings.sort_unstable();
1121
1122 SemanticUsage {
1123 import_binding_usage: ImportBindingUsage {
1124 unused,
1125 type_referenced: type_referenced_bindings,
1126 value_referenced: value_referenced_bindings,
1127 },
1128 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1129 }
1130}
1131
1132pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
1133 use oxc_semantic::SemanticBuilder;
1134
1135 let semantic_ret = SemanticBuilder::new().build(program);
1136 let semantic = semantic_ret.semantic;
1137 compute_auto_import_candidates_from_semantic(semantic.scoping())
1138}
1139
1140fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1141 use rustc_hash::FxHashSet;
1142
1143 let mut candidates: FxHashSet<String> = FxHashSet::default();
1144 for (name, reference_ids) in scoping.root_unresolved_references() {
1145 if reference_ids
1146 .iter()
1147 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1148 {
1149 candidates.insert(name.as_str().to_string());
1150 }
1151 }
1152
1153 let mut candidates: Vec<String> = candidates.into_iter().collect();
1154 candidates.sort_unstable();
1155 candidates
1156}
1157
1158pub fn compute_import_binding_usage(
1175 program: &Program<'_>,
1176 imports: &[ImportInfo],
1177 template_used: &rustc_hash::FxHashSet<String>,
1178) -> ImportBindingUsage {
1179 compute_semantic_usage(program, imports, template_used).import_binding_usage
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::{
1185 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
1186 scan_jsdoc_imports_in,
1187 };
1188 use fallow_types::discover::FileId;
1189 use fallow_types::extract::{ImportInfo, ImportedName};
1190 use std::path::Path;
1191
1192 #[test]
1193 fn has_public_tag_matches_bare_tag() {
1194 assert!(has_public_tag(" * @public"));
1195 }
1196
1197 #[test]
1198 fn has_public_tag_matches_api_public_variant() {
1199 assert!(has_public_tag(" * @api public"));
1200 }
1201
1202 #[test]
1203 fn has_public_tag_rejects_partial_word() {
1204 assert!(!has_public_tag(" * @publicly"));
1205 }
1206
1207 #[test]
1208 fn has_public_tag_rejects_at_apipublic() {
1209 assert!(!has_public_tag(" * @apipublic"));
1210 }
1211
1212 #[test]
1213 fn has_public_tag_rejects_missing_at() {
1214 assert!(!has_public_tag(" * public"));
1215 }
1216
1217 #[test]
1218 fn has_internal_tag_matches_bare_tag() {
1219 assert!(has_internal_tag(" * @internal"));
1220 }
1221
1222 #[test]
1223 fn has_internal_tag_rejects_partial_word() {
1224 assert!(!has_internal_tag(" * @internalizer"));
1225 }
1226
1227 #[test]
1228 fn has_internal_tag_rejects_missing_at() {
1229 assert!(!has_internal_tag(" * internal"));
1230 }
1231
1232 #[test]
1233 fn has_beta_tag_matches_bare_tag() {
1234 assert!(has_beta_tag(" * @beta"));
1235 }
1236
1237 #[test]
1238 fn has_beta_tag_rejects_partial_word() {
1239 assert!(!has_beta_tag(" * @betaware"));
1240 }
1241
1242 #[test]
1243 fn has_beta_tag_rejects_missing_at() {
1244 assert!(!has_beta_tag(" * beta"));
1245 }
1246
1247 #[test]
1248 fn alpha_tag_standalone() {
1249 assert!(has_alpha_tag("@alpha"));
1250 }
1251
1252 #[test]
1253 fn alpha_tag_with_text() {
1254 assert!(has_alpha_tag("@alpha Some description"));
1255 }
1256
1257 #[test]
1258 fn alpha_tag_not_prefix() {
1259 assert!(!has_alpha_tag("@alphabet"));
1260 }
1261
1262 #[test]
1263 fn has_alpha_tag_rejects_missing_at() {
1264 assert!(!has_alpha_tag(" * alpha"));
1265 }
1266
1267 fn scan(body: &str) -> Vec<ImportInfo> {
1268 let mut imports = Vec::new();
1269 scan_jsdoc_imports_in(body, &mut imports);
1270 imports
1271 }
1272
1273 #[test]
1274 fn scan_jsdoc_single_import_with_member() {
1275 let imports = scan(" * @param foo {import('./types').Foo}");
1276 assert_eq!(imports.len(), 1);
1277 assert_eq!(imports[0].source, "./types");
1278 assert_eq!(
1279 imports[0].imported_name,
1280 ImportedName::Named("Foo".to_string())
1281 );
1282 assert!(imports[0].is_type_only);
1283 assert!(imports[0].local_name.is_empty());
1284 }
1285
1286 #[test]
1287 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1288 let info = parse_source_to_module(
1289 FileId(0),
1290 Path::new("pages/index.ts"),
1291 r"
1292 useCounter();
1293 const price = formatPrice(10);
1294 const localOnly = () => null;
1295 localOnly();
1296 type Local = UseTypeOnly;
1297 ",
1298 0,
1299 false,
1300 );
1301
1302 assert!(
1303 info.auto_import_candidates
1304 .contains(&"formatPrice".to_string())
1305 );
1306 assert!(
1307 info.auto_import_candidates
1308 .contains(&"useCounter".to_string())
1309 );
1310 assert!(
1311 !info
1312 .auto_import_candidates
1313 .contains(&"UseTypeOnly".to_string())
1314 );
1315 assert!(
1316 !info
1317 .auto_import_candidates
1318 .contains(&"localOnly".to_string())
1319 );
1320 }
1321
1322 #[test]
1323 fn script_auto_import_candidates_skip_explicit_imports() {
1324 let info = parse_source_to_module(
1325 FileId(0),
1326 Path::new("pages/index.ts"),
1327 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1328 0,
1329 false,
1330 );
1331
1332 assert!(
1333 !info
1334 .auto_import_candidates
1335 .contains(&"useCounter".to_string())
1336 );
1337 assert!(
1338 info.auto_import_candidates
1339 .contains(&"useOther".to_string())
1340 );
1341 }
1342
1343 #[test]
1344 fn scan_jsdoc_double_quoted_path() {
1345 let imports = scan(r#" * @type {import("./types").Foo}"#);
1346 assert_eq!(imports.len(), 1);
1347 assert_eq!(imports[0].source, "./types");
1348 }
1349
1350 #[test]
1351 fn scan_jsdoc_multiple_imports_in_same_body() {
1352 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1353 assert_eq!(imports.len(), 2);
1354 assert_eq!(imports[0].source, "./a");
1355 assert_eq!(imports[1].source, "./b");
1356 }
1357
1358 #[test]
1359 fn scan_jsdoc_union_annotation_captures_both_members() {
1360 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1361 assert_eq!(imports.len(), 2);
1362 assert_eq!(
1363 imports[0].imported_name,
1364 ImportedName::Named("A".to_string())
1365 );
1366 assert_eq!(
1367 imports[1].imported_name,
1368 ImportedName::Named("B".to_string())
1369 );
1370 }
1371
1372 #[test]
1373 fn scan_jsdoc_nested_member_uses_first_segment() {
1374 let imports = scan(" * @type {import('./types').ns.Foo}");
1375 assert_eq!(imports.len(), 1);
1376 assert_eq!(
1377 imports[0].imported_name,
1378 ImportedName::Named("ns".to_string())
1379 );
1380 }
1381
1382 #[test]
1383 fn scan_jsdoc_parent_relative_path() {
1384 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1385 assert_eq!(imports.len(), 1);
1386 assert_eq!(imports[0].source, "../lib/types.js");
1387 }
1388
1389 #[test]
1390 fn scan_jsdoc_bare_package_specifier() {
1391 let imports = scan(" * @type {import('@scope/pkg').Client}");
1392 assert_eq!(imports.len(), 1);
1393 assert_eq!(imports[0].source, "@scope/pkg");
1394 assert_eq!(
1395 imports[0].imported_name,
1396 ImportedName::Named("Client".to_string())
1397 );
1398 }
1399
1400 #[test]
1401 fn scan_jsdoc_without_member_is_side_effect() {
1402 let imports = scan(" * @type {import('./types')}");
1403 assert_eq!(imports.len(), 1);
1404 assert_eq!(imports[0].source, "./types");
1405 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1406 assert!(imports[0].is_type_only);
1407 }
1408
1409 #[test]
1410 fn scan_jsdoc_empty_path_is_skipped() {
1411 let imports = scan(" * @type {import('').Foo}");
1412 assert!(imports.is_empty());
1413 }
1414
1415 #[test]
1416 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1417 let imports = scan(" * @type {import('./truncated");
1418 assert!(imports.is_empty());
1419 }
1420
1421 #[test]
1422 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1423 let imports = scan(" * @type {import('./types'.Foo}");
1424 assert!(imports.is_empty());
1425 }
1426
1427 #[test]
1428 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1429 let imports = scan(" * @type {import('./types') .Foo}");
1430 assert_eq!(imports.len(), 1);
1431 assert_eq!(imports[0].source, "./types");
1432 assert_eq!(
1433 imports[0].imported_name,
1434 ImportedName::Named("Foo".to_string())
1435 );
1436 }
1437
1438 #[test]
1439 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1440 let imports = scan(" * @type {import( './types').Foo}");
1441 assert_eq!(imports.len(), 1);
1442 assert_eq!(imports[0].source, "./types");
1443 }
1444
1445 #[test]
1446 fn scan_jsdoc_non_quote_after_paren_skipped() {
1447 let imports = scan(" * @type {import(foo).Bar}");
1448 assert!(imports.is_empty());
1449 }
1450
1451 #[test]
1452 fn scan_jsdoc_ignores_prose_with_import_word() {
1453 let imports = scan(" * This is an important note about imports.");
1454 assert!(imports.is_empty());
1455 }
1456
1457 #[test]
1458 fn scan_jsdoc_utf8_path_works() {
1459 let imports = scan(" * @type {import('./héllo').Foo}");
1460 assert_eq!(imports.len(), 1);
1461 assert_eq!(imports[0].source, "./héllo");
1462 }
1463
1464 #[test]
1465 fn scan_jsdoc_empty_body_is_empty() {
1466 assert!(scan("").is_empty());
1467 }
1468
1469 #[test]
1470 fn scan_jsdoc_no_import_in_body_is_empty() {
1471 assert!(scan(" * @param foo The foo parameter").is_empty());
1472 }
1473
1474 #[test]
1480 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1481 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1484 let imports = scan(body);
1485 assert!(
1486 imports.is_empty(),
1487 "prose import() should not be matched; got: {:?}",
1488 imports
1489 .iter()
1490 .map(|i| i.source.as_str())
1491 .collect::<Vec<_>>()
1492 );
1493 }
1494
1495 #[test]
1496 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1497 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1498 let imports = scan(body);
1499 assert!(
1500 imports.is_empty(),
1501 "object-literal example import() should not be matched; got: {:?}",
1502 imports
1503 .iter()
1504 .map(|i| i.source.as_str())
1505 .collect::<Vec<_>>()
1506 );
1507 }
1508
1509 #[test]
1510 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1511 let imports = scan(" * Use {import('./prose')} as an example string.");
1512 assert!(imports.is_empty());
1513 }
1514
1515 #[test]
1516 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1517 let imports = scan("\n * @example\n * { import('./prose') }\n");
1518 assert!(imports.is_empty());
1519 }
1520
1521 #[test]
1525 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1526 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1527 let imports = scan(body);
1528 assert_eq!(imports.len(), 1, "got: {imports:?}");
1529 assert_eq!(imports[0].source, "./real");
1530 assert_eq!(
1531 imports[0].imported_name,
1532 ImportedName::Named("Foo".to_string())
1533 );
1534 }
1535
1536 #[test]
1537 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1538 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1539 let imports = scan(body);
1540 assert_eq!(imports.len(), 1, "got: {imports:?}");
1541 assert_eq!(imports[0].source, "./real");
1542 assert_eq!(
1543 imports[0].imported_name,
1544 ImportedName::Named("Foo".to_string())
1545 );
1546 }
1547
1548 #[test]
1549 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1550 let body = "\n * @type\n * { import('./real').Foo }\n";
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_satisfies_type_tag_is_still_matched() {
1562 let imports = scan(" * @satisfies {import('./real').Foo}");
1563 assert_eq!(imports.len(), 1, "got: {imports:?}");
1564 assert_eq!(imports[0].source, "./real");
1565 assert_eq!(
1566 imports[0].imported_name,
1567 ImportedName::Named("Foo".to_string())
1568 );
1569 }
1570
1571 #[test]
1572 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1573 let imports = scan(" * @template {import('./real').Foo} T");
1574 assert_eq!(imports.len(), 1, "got: {imports:?}");
1575 assert_eq!(imports[0].source, "./real");
1576 assert_eq!(
1577 imports[0].imported_name,
1578 ImportedName::Named("Foo".to_string())
1579 );
1580 }
1581
1582 #[test]
1583 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1584 let imports = scan(" * @enum {import('./real').Foo}");
1585 assert_eq!(imports.len(), 1, "got: {imports:?}");
1586 assert_eq!(imports[0].source, "./real");
1587 assert_eq!(
1588 imports[0].imported_name,
1589 ImportedName::Named("Foo".to_string())
1590 );
1591 }
1592
1593 #[test]
1594 fn scan_jsdoc_appends_to_existing_imports() {
1595 let mut imports = vec![ImportInfo {
1596 source: "existing".to_string(),
1597 imported_name: ImportedName::Default,
1598 local_name: "existing".to_string(),
1599 is_type_only: false,
1600 from_style: false,
1601 span: oxc_span::Span::default(),
1602 source_span: oxc_span::Span::default(),
1603 }];
1604 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1605 assert_eq!(imports.len(), 2);
1606 assert_eq!(imports[0].source, "existing");
1607 assert_eq!(imports[1].source, "./new");
1608 }
1609
1610 #[test]
1611 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1612 let imports = scan(" * @type {import('./t').Abc}");
1613 assert_eq!(imports.len(), 1);
1614 assert_eq!(
1615 imports[0].imported_name,
1616 ImportedName::Named("Abc".to_string())
1617 );
1618 }
1619
1620 #[test]
1621 fn scan_jsdoc_empty_member_name_is_skipped() {
1622 let imports = scan(" * @type {import('./x').}");
1623 assert!(imports.is_empty());
1624 }
1625}