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;
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 !is_sveltekit_page_load_file(path) {
60 module.load_return_keys = Vec::new();
61 module.has_unharvestable_load = false;
62 }
63 module
64}
65
66fn is_sveltekit_page_load_file(path: &Path) -> bool {
71 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
72 return false;
73 };
74 matches!(
75 name,
76 "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
77 )
78}
79
80fn parse_source_to_module_inner(
81 file_id: FileId,
82 path: &Path,
83 source: &str,
84 content_hash: u64,
85 need_complexity: bool,
86) -> ModuleInfo {
87 let source = crate::strip_bom(source);
88 if let Some(module) =
89 parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
90 {
91 return module;
92 }
93
94 let stripped_glimmer_source = is_glimmer_file(path)
95 .then(|| strip_glimmer_templates(source))
96 .flatten();
97 let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
98 let source_type = source_type_for_path(path);
99 let allocator = Allocator::default();
100 let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
101
102 let mut parsed_suppressions =
103 crate::suppress::parse_suppressions(&parser_return.program.comments, source);
104
105 let mut extractor = ModuleInfoExtractor::new();
106 extractor.jsx_capable = source_type.is_jsx();
110 extractor.visit_program(&parser_return.program);
111 extractor.resolve_pending_local_export_specifiers();
112
113 let template_used_imports =
114 collect_glimmer_template_into_extractor(&mut extractor, path, source);
115
116 let mut semantic_usage = compute_semantic_usage(
117 &parser_return.program,
118 &extractor.imports,
119 &template_used_imports,
120 );
121
122 let line_offsets = fallow_types::extract::compute_line_offsets(source);
123
124 let mut complexity = if need_complexity {
125 crate::complexity::compute_complexity(&parser_return.program, parser_source, &line_offsets)
126 } else {
127 Vec::new()
128 };
129 if need_complexity {
130 append_inline_template_complexity(
131 &mut complexity,
132 &extractor.inline_template_findings,
133 &line_offsets,
134 );
135 }
136
137 let mut flag_uses = crate::flags::extract_flags(
138 &parser_return.program,
139 &line_offsets,
140 &[], &[], false, );
144
145 let total_extracted =
146 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
147 let retry_input = JsxRetryInput {
148 path,
149 source,
150 parser_source,
151 source_type,
152 total_extracted,
153 need_complexity,
154 line_offsets: &line_offsets,
155 };
156 let used_retry = if let Some(retry) = parse_with_jsx_retry(&retry_input) {
157 extractor = retry.extractor;
158 semantic_usage = retry.semantic_usage;
159 complexity = retry.complexity;
160 flag_uses = retry.flag_uses;
161 parsed_suppressions = retry.parsed_suppressions;
162 true
163 } else {
164 false
165 };
166
167 if !used_retry {
168 apply_jsdoc_visibility_tags(
169 &mut extractor.exports,
170 &parser_return.program.comments,
171 source,
172 );
173 extract_jsdoc_import_types(
174 &mut extractor.imports,
175 &parser_return.program.comments,
176 source,
177 );
178 }
179
180 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
181 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
182 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
183 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
184 info.auto_import_candidates = semantic_usage.auto_import_candidates;
185 info.line_offsets = line_offsets;
186 info.complexity = complexity;
187 info.flag_uses = flag_uses;
188
189 info
190}
191
192struct JsxRetryInput<'a> {
193 path: &'a Path,
194 source: &'a str,
195 parser_source: &'a str,
196 source_type: SourceType,
197 total_extracted: usize,
198 need_complexity: bool,
199 line_offsets: &'a [u32],
200}
201
202fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
203 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
204 return None;
205 }
206
207 let jsx_type = if input.source_type.is_typescript() {
208 SourceType::tsx()
209 } else {
210 SourceType::jsx()
211 };
212 let allocator = Allocator::default();
213 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
214 let mut extractor = ModuleInfoExtractor::new();
215 extractor.jsx_capable = true;
218 extractor.visit_program(&retry_return.program);
219 extractor.resolve_pending_local_export_specifiers();
220 let retry_total =
221 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
222 if retry_total <= input.total_extracted {
223 return None;
224 }
225
226 let template_used_imports =
227 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
228 let semantic_usage = compute_semantic_usage(
229 &retry_return.program,
230 &extractor.imports,
231 &template_used_imports,
232 );
233 let complexity = retry_complexity(
234 input.need_complexity,
235 &retry_return.program,
236 input.parser_source,
237 input.line_offsets,
238 &extractor,
239 );
240 let flag_uses =
241 crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
242 let parsed_suppressions =
243 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
244 apply_jsdoc_visibility_tags(
245 &mut extractor.exports,
246 &retry_return.program.comments,
247 input.source,
248 );
249 extract_jsdoc_import_types(
250 &mut extractor.imports,
251 &retry_return.program.comments,
252 input.source,
253 );
254 Some(JsxRetryParse {
255 extractor,
256 semantic_usage,
257 complexity,
258 flag_uses,
259 parsed_suppressions,
260 })
261}
262
263fn retry_complexity(
264 need_complexity: bool,
265 program: &Program<'_>,
266 parser_source: &str,
267 line_offsets: &[u32],
268 extractor: &ModuleInfoExtractor,
269) -> Vec<FunctionComplexity> {
270 if !need_complexity {
271 return Vec::new();
272 }
273 let mut complexity =
274 crate::complexity::compute_complexity(program, parser_source, line_offsets);
275 append_inline_template_complexity(
276 &mut complexity,
277 &extractor.inline_template_findings,
278 line_offsets,
279 );
280 complexity
281}
282
283fn parse_non_js_source_to_module(
284 file_id: FileId,
285 path: &Path,
286 source: &str,
287 content_hash: u64,
288 need_complexity: bool,
289) -> Option<ModuleInfo> {
290 if is_sfc_file(path) {
291 return Some(parse_sfc_to_module(
292 file_id,
293 path,
294 source,
295 content_hash,
296 need_complexity,
297 ));
298 }
299 if is_astro_file(path) {
300 return Some(parse_astro_to_module(file_id, source, content_hash));
301 }
302 if is_mdx_file(path) {
303 return Some(parse_mdx_to_module(file_id, source, content_hash));
304 }
305 if is_css_file(path) {
306 return Some(parse_css_to_module(file_id, path, source, content_hash));
307 }
308 if is_graphql_file(path) {
309 return Some(parse_graphql_to_module(file_id, source, content_hash));
310 }
311 if is_html_file(path) {
312 return Some(parse_html_to_module_with_complexity(
313 file_id,
314 source,
315 content_hash,
316 need_complexity,
317 ));
318 }
319 None
320}
321
322fn collect_glimmer_template_into_extractor(
344 extractor: &mut ModuleInfoExtractor,
345 path: &Path,
346 source: &str,
347) -> rustc_hash::FxHashSet<String> {
348 use rustc_hash::FxHashSet;
349
350 if !is_glimmer_file(path) {
351 return FxHashSet::default();
352 }
353 let template_ranges = crate::glimmer::find_template_ranges(source);
354 if template_ranges.is_empty() {
355 return FxHashSet::default();
356 }
357
358 let imported_bindings: FxHashSet<String> = extractor
359 .imports
360 .iter()
361 .filter(|import| !import.local_name.is_empty())
362 .map(|import| import.local_name.clone())
363 .collect();
364
365 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
366 source,
367 &template_ranges,
368 &imported_bindings,
369 );
370 extractor.member_accesses.extend(usage.member_accesses);
371 usage.used_bindings
372}
373
374fn append_inline_template_complexity(
385 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
386 findings: &[crate::visitor::InlineTemplateFinding],
387 line_offsets: &[u32],
388) {
389 for finding in findings {
390 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
391 &finding.template_source,
392 ) else {
393 continue;
394 };
395 let (line, col) =
396 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
397 fc.line = line;
398 fc.col = col;
399 complexity.push(fc);
400 }
401}
402
403fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
413 if exports.is_empty() || comments.is_empty() {
414 return;
415 }
416
417 let mut tag_offsets: Vec<(u32, VisibilityTag)> = Vec::new();
418 for comment in comments {
419 if comment.is_jsdoc() {
420 let content_span = comment.content_span();
421 let start = content_span.start as usize;
422 let end = (content_span.end as usize).min(source.len());
423 if start < end {
424 let text = &source[start..end];
425 let tag = if has_public_tag(text) {
426 VisibilityTag::Public
427 } else if has_internal_tag(text) {
428 VisibilityTag::Internal
429 } else if has_alpha_tag(text) {
430 VisibilityTag::Alpha
431 } else if has_beta_tag(text) {
432 VisibilityTag::Beta
433 } else if has_expected_unused_tag(text) {
434 VisibilityTag::ExpectedUnused
435 } else {
436 continue;
437 };
438 tag_offsets.push((comment.attached_to, tag));
439 }
440 }
441 }
442
443 if tag_offsets.is_empty() {
444 return;
445 }
446
447 tag_offsets.sort_unstable_by_key(|&(offset, _)| offset);
448
449 for export in exports.iter_mut() {
450 if export.span.start == 0 && export.span.end == 0 {
451 continue;
452 }
453
454 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _)| o) {
455 export.visibility = tag_offsets[idx].1;
456 continue;
457 }
458
459 let idx = tag_offsets.partition_point(|&(o, _)| o <= export.span.start);
460 if idx > 0 {
461 let (offset, tag) = tag_offsets[idx - 1];
462 let offset = offset as usize;
463 let export_start = export.span.start as usize;
464 if offset < export_start && export_start <= source.len() {
465 let between = &source[offset..export_start];
466 if between.starts_with("export") && !between.contains(';') && !between.contains('}')
467 {
468 export.visibility = tag;
469 }
470 }
471 }
472 }
473}
474
475fn has_internal_tag(comment_text: &str) -> bool {
477 for (i, _) in comment_text.match_indices("@internal") {
478 let after = i + "@internal".len();
479 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
480 return true;
481 }
482 }
483 false
484}
485
486fn has_beta_tag(comment_text: &str) -> bool {
488 for (i, _) in comment_text.match_indices("@beta") {
489 let after = i + "@beta".len();
490 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
491 return true;
492 }
493 }
494 false
495}
496
497fn has_alpha_tag(comment_text: &str) -> bool {
499 for (i, _) in comment_text.match_indices("@alpha") {
500 let after = i + "@alpha".len();
501 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
502 return true;
503 }
504 }
505 false
506}
507
508fn has_expected_unused_tag(comment_text: &str) -> bool {
510 for (i, _) in comment_text.match_indices("@expected-unused") {
511 let after = i + "@expected-unused".len();
512 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
513 return true;
514 }
515 }
516 false
517}
518
519const fn is_ident_char(b: u8) -> bool {
521 b.is_ascii_alphanumeric() || b == b'_'
522}
523
524fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
548 if comments.is_empty() {
549 return;
550 }
551
552 for comment in comments {
553 if !comment.is_jsdoc() {
554 continue;
555 }
556 let content_span = comment.content_span();
557 let start = content_span.start as usize;
558 let end = (content_span.end as usize).min(source.len());
559 if start >= end {
560 continue;
561 }
562 scan_jsdoc_imports_in(&source[start..end], imports);
563 }
564}
565
566fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
574 let bytes = body.as_bytes();
575 let mut cursor = 0;
576 while let Some(rel) = body[cursor..].find("import(") {
577 let import_pos = cursor + rel;
578 if !is_inside_jsdoc_type_brace_group(bytes, import_pos) {
579 cursor = import_pos + "import(".len();
580 continue;
581 }
582 let open = import_pos + "import(".len();
583 cursor = open;
584 if open >= bytes.len() {
585 break;
586 }
587 let mut i = open;
588 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
589 i += 1;
590 }
591 if i >= bytes.len() {
592 break;
593 }
594 let quote = bytes[i];
595 if quote != b'\'' && quote != b'"' {
596 continue;
597 }
598 let path_start = i + 1;
599 let Some(rel_close) = body[path_start..].find(quote as char) else {
600 break;
601 };
602 let path_end = path_start + rel_close;
603 let path = &body[path_start..path_end];
604 if path.is_empty() {
605 cursor = path_end + 1;
606 continue;
607 }
608 let mut j = path_end + 1;
609 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
610 j += 1;
611 }
612 if j >= bytes.len() || bytes[j] != b')' {
613 cursor = path_end + 1;
614 continue;
615 }
616 j += 1;
617 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
618 j += 1;
619 }
620 cursor = j;
621 if j >= bytes.len() || bytes[j] != b'.' {
622 imports.push(ImportInfo {
623 source: path.to_string(),
624 imported_name: fallow_types::extract::ImportedName::SideEffect,
625 local_name: String::new(),
626 is_type_only: true,
627 from_style: false,
628 span: oxc_span::Span::default(),
629 source_span: oxc_span::Span::default(),
630 });
631 continue;
632 }
633 j += 1;
634 let name_start = j;
635 while j < bytes.len() && is_ident_char(bytes[j]) {
636 j += 1;
637 }
638 if name_start == j {
639 continue;
640 }
641 let member = &body[name_start..j];
642 cursor = j;
643 imports.push(ImportInfo {
644 source: path.to_string(),
645 imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
646 local_name: String::new(),
647 is_type_only: true,
648 from_style: false,
649 span: oxc_span::Span::default(),
650 source_span: oxc_span::Span::default(),
651 });
652 }
653}
654
655fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize) -> bool {
659 let Some(open_brace) = enclosing_jsdoc_brace_start(body, pos) else {
660 return false;
661 };
662
663 let prefix = line_prefix_before(body, open_brace);
664 if jsdoc_line_prefix_has_type_tag(prefix) {
665 return true;
666 }
667
668 strip_jsdoc_line_prefix(prefix).is_empty()
669 && preceding_jsdoc_line_has_type_tag(body, open_brace)
670 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
671}
672
673fn enclosing_jsdoc_brace_start(body: &[u8], pos: usize) -> Option<usize> {
674 let mut stack = Vec::new();
675 let limit = pos.min(body.len());
676 for (idx, &b) in body[..limit].iter().enumerate() {
677 match b {
678 b'{' => stack.push(idx),
679 b'}' => {
680 stack.pop();
681 }
682 _ => {}
683 }
684 }
685 stack.pop()
686}
687
688fn line_prefix_before(body: &[u8], pos: usize) -> &str {
689 let start = body[..pos]
690 .iter()
691 .rposition(|&b| b == b'\n')
692 .map_or(0, |idx| idx + 1);
693 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
694}
695
696fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
697 let trimmed = prefix.trim_start();
698 trimmed
699 .strip_prefix('*')
700 .map_or(trimmed, |rest| rest.trim_start())
701}
702
703fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
704 const TYPE_TAGS: [&str; 17] = [
705 "@arg",
706 "@argument",
707 "@augments",
708 "@callback",
709 "@enum",
710 "@extends",
711 "@implements",
712 "@param",
713 "@property",
714 "@prop",
715 "@return",
716 "@returns",
717 "@satisfies",
718 "@template",
719 "@this",
720 "@type",
721 "@typedef",
722 ];
723
724 let prefix = strip_jsdoc_line_prefix(prefix);
725 TYPE_TAGS
726 .iter()
727 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
728}
729
730fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
731 for (idx, _) in text.match_indices(tag) {
732 let after = idx + tag.len();
733 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
734 return true;
735 }
736 }
737 false
738}
739
740fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
741 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
742 return false;
743 };
744
745 let line_start = body[..line_end]
746 .iter()
747 .rposition(|&b| b == b'\n')
748 .map_or(0, |idx| idx + 1);
749
750 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
751}
752
753fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
754 let mut at_line_start = true;
755 let mut i = start.min(body.len());
756 let end = end.min(body.len());
757 while i < end {
758 match body[i] {
759 b'\n' => {
760 at_line_start = true;
761 i += 1;
762 }
763 b'\r' | b'\t' | b' ' => {
764 i += 1;
765 }
766 b'*' if at_line_start => {
767 at_line_start = false;
768 i += 1;
769 }
770 _ => return false,
771 }
772 }
773 true
774}
775
776fn has_public_tag(comment_text: &str) -> bool {
778 for (i, _) in comment_text.match_indices("@public") {
779 let after = i + "@public".len();
780 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
781 return true;
782 }
783 }
784 for (i, _) in comment_text.match_indices("@api") {
785 let after = i + "@api".len();
786 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
787 let rest = comment_text[after..].trim_start();
788 if rest.starts_with("public") {
789 let after_public = "public".len();
790 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
791 return true;
792 }
793 }
794 }
795 }
796 false
797}
798
799#[derive(Debug, Default, PartialEq, Eq)]
800pub struct ImportBindingUsage {
801 pub unused: Vec<String>,
802 pub type_referenced: Vec<String>,
803 pub value_referenced: Vec<String>,
804}
805
806#[derive(Debug, Default, PartialEq, Eq)]
807pub struct SemanticUsage {
808 pub import_binding_usage: ImportBindingUsage,
809 pub auto_import_candidates: Vec<String>,
810}
811
812pub fn compute_semantic_usage(
813 program: &Program<'_>,
814 imports: &[ImportInfo],
815 template_used: &rustc_hash::FxHashSet<String>,
816) -> SemanticUsage {
817 use oxc_semantic::SemanticBuilder;
818 use rustc_hash::FxHashSet;
819
820 let semantic_ret = SemanticBuilder::new().build(program);
821 let semantic = semantic_ret.semantic;
822 let scoping = semantic.scoping();
823 let root_scope = scoping.root_scope_id();
824
825 let mut unused = Vec::new();
826 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
827 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
828 for import in imports {
829 if import.local_name.is_empty() {
830 continue;
831 }
832 let name = oxc_str::Ident::from(import.local_name.as_str());
833 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
834 let mut has_references = false;
835 let mut has_type_references = false;
836 let mut has_value_references = false;
837
838 for reference in scoping.get_resolved_references(symbol_id) {
839 has_references = true;
840 has_type_references |= reference.is_type();
841 has_value_references |= reference.is_value();
842 }
843
844 if !has_references {
845 if !template_used.contains(&import.local_name) {
846 unused.push(import.local_name.clone());
847 }
848 continue;
849 }
850
851 if has_type_references {
852 type_referenced_bindings.insert(import.local_name.clone());
853 }
854 if has_value_references {
855 value_referenced_bindings.insert(import.local_name.clone());
856 }
857 }
858 }
859
860 unused.sort_unstable();
861
862 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
863 type_referenced_bindings.sort_unstable();
864
865 let mut value_referenced_bindings: Vec<String> =
866 value_referenced_bindings.into_iter().collect();
867 value_referenced_bindings.sort_unstable();
868
869 SemanticUsage {
870 import_binding_usage: ImportBindingUsage {
871 unused,
872 type_referenced: type_referenced_bindings,
873 value_referenced: value_referenced_bindings,
874 },
875 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
876 }
877}
878
879pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
880 use oxc_semantic::SemanticBuilder;
881
882 let semantic_ret = SemanticBuilder::new().build(program);
883 let semantic = semantic_ret.semantic;
884 compute_auto_import_candidates_from_semantic(semantic.scoping())
885}
886
887fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
888 use rustc_hash::FxHashSet;
889
890 let mut candidates: FxHashSet<String> = FxHashSet::default();
891 for (name, reference_ids) in scoping.root_unresolved_references() {
892 if reference_ids
893 .iter()
894 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
895 {
896 candidates.insert(name.as_str().to_string());
897 }
898 }
899
900 let mut candidates: Vec<String> = candidates.into_iter().collect();
901 candidates.sort_unstable();
902 candidates
903}
904
905pub fn compute_import_binding_usage(
922 program: &Program<'_>,
923 imports: &[ImportInfo],
924 template_used: &rustc_hash::FxHashSet<String>,
925) -> ImportBindingUsage {
926 compute_semantic_usage(program, imports, template_used).import_binding_usage
927}
928
929#[cfg(test)]
930mod tests {
931 use super::{
932 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
933 scan_jsdoc_imports_in,
934 };
935 use fallow_types::discover::FileId;
936 use fallow_types::extract::{ImportInfo, ImportedName};
937 use std::path::Path;
938
939 #[test]
940 fn has_public_tag_matches_bare_tag() {
941 assert!(has_public_tag(" * @public"));
942 }
943
944 #[test]
945 fn has_public_tag_matches_api_public_variant() {
946 assert!(has_public_tag(" * @api public"));
947 }
948
949 #[test]
950 fn has_public_tag_rejects_partial_word() {
951 assert!(!has_public_tag(" * @publicly"));
952 }
953
954 #[test]
955 fn has_public_tag_rejects_at_apipublic() {
956 assert!(!has_public_tag(" * @apipublic"));
957 }
958
959 #[test]
960 fn has_public_tag_rejects_missing_at() {
961 assert!(!has_public_tag(" * public"));
962 }
963
964 #[test]
965 fn has_internal_tag_matches_bare_tag() {
966 assert!(has_internal_tag(" * @internal"));
967 }
968
969 #[test]
970 fn has_internal_tag_rejects_partial_word() {
971 assert!(!has_internal_tag(" * @internalizer"));
972 }
973
974 #[test]
975 fn has_internal_tag_rejects_missing_at() {
976 assert!(!has_internal_tag(" * internal"));
977 }
978
979 #[test]
980 fn has_beta_tag_matches_bare_tag() {
981 assert!(has_beta_tag(" * @beta"));
982 }
983
984 #[test]
985 fn has_beta_tag_rejects_partial_word() {
986 assert!(!has_beta_tag(" * @betaware"));
987 }
988
989 #[test]
990 fn has_beta_tag_rejects_missing_at() {
991 assert!(!has_beta_tag(" * beta"));
992 }
993
994 #[test]
995 fn alpha_tag_standalone() {
996 assert!(has_alpha_tag("@alpha"));
997 }
998
999 #[test]
1000 fn alpha_tag_with_text() {
1001 assert!(has_alpha_tag("@alpha Some description"));
1002 }
1003
1004 #[test]
1005 fn alpha_tag_not_prefix() {
1006 assert!(!has_alpha_tag("@alphabet"));
1007 }
1008
1009 #[test]
1010 fn has_alpha_tag_rejects_missing_at() {
1011 assert!(!has_alpha_tag(" * alpha"));
1012 }
1013
1014 fn scan(body: &str) -> Vec<ImportInfo> {
1015 let mut imports = Vec::new();
1016 scan_jsdoc_imports_in(body, &mut imports);
1017 imports
1018 }
1019
1020 #[test]
1021 fn scan_jsdoc_single_import_with_member() {
1022 let imports = scan(" * @param foo {import('./types').Foo}");
1023 assert_eq!(imports.len(), 1);
1024 assert_eq!(imports[0].source, "./types");
1025 assert_eq!(
1026 imports[0].imported_name,
1027 ImportedName::Named("Foo".to_string())
1028 );
1029 assert!(imports[0].is_type_only);
1030 assert!(imports[0].local_name.is_empty());
1031 }
1032
1033 #[test]
1034 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1035 let info = parse_source_to_module(
1036 FileId(0),
1037 Path::new("pages/index.ts"),
1038 r"
1039 useCounter();
1040 const price = formatPrice(10);
1041 const localOnly = () => null;
1042 localOnly();
1043 type Local = UseTypeOnly;
1044 ",
1045 0,
1046 false,
1047 );
1048
1049 assert!(
1050 info.auto_import_candidates
1051 .contains(&"formatPrice".to_string())
1052 );
1053 assert!(
1054 info.auto_import_candidates
1055 .contains(&"useCounter".to_string())
1056 );
1057 assert!(
1058 !info
1059 .auto_import_candidates
1060 .contains(&"UseTypeOnly".to_string())
1061 );
1062 assert!(
1063 !info
1064 .auto_import_candidates
1065 .contains(&"localOnly".to_string())
1066 );
1067 }
1068
1069 #[test]
1070 fn script_auto_import_candidates_skip_explicit_imports() {
1071 let info = parse_source_to_module(
1072 FileId(0),
1073 Path::new("pages/index.ts"),
1074 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1075 0,
1076 false,
1077 );
1078
1079 assert!(
1080 !info
1081 .auto_import_candidates
1082 .contains(&"useCounter".to_string())
1083 );
1084 assert!(
1085 info.auto_import_candidates
1086 .contains(&"useOther".to_string())
1087 );
1088 }
1089
1090 #[test]
1091 fn scan_jsdoc_double_quoted_path() {
1092 let imports = scan(r#" * @type {import("./types").Foo}"#);
1093 assert_eq!(imports.len(), 1);
1094 assert_eq!(imports[0].source, "./types");
1095 }
1096
1097 #[test]
1098 fn scan_jsdoc_multiple_imports_in_same_body() {
1099 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1100 assert_eq!(imports.len(), 2);
1101 assert_eq!(imports[0].source, "./a");
1102 assert_eq!(imports[1].source, "./b");
1103 }
1104
1105 #[test]
1106 fn scan_jsdoc_union_annotation_captures_both_members() {
1107 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1108 assert_eq!(imports.len(), 2);
1109 assert_eq!(
1110 imports[0].imported_name,
1111 ImportedName::Named("A".to_string())
1112 );
1113 assert_eq!(
1114 imports[1].imported_name,
1115 ImportedName::Named("B".to_string())
1116 );
1117 }
1118
1119 #[test]
1120 fn scan_jsdoc_nested_member_uses_first_segment() {
1121 let imports = scan(" * @type {import('./types').ns.Foo}");
1122 assert_eq!(imports.len(), 1);
1123 assert_eq!(
1124 imports[0].imported_name,
1125 ImportedName::Named("ns".to_string())
1126 );
1127 }
1128
1129 #[test]
1130 fn scan_jsdoc_parent_relative_path() {
1131 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1132 assert_eq!(imports.len(), 1);
1133 assert_eq!(imports[0].source, "../lib/types.js");
1134 }
1135
1136 #[test]
1137 fn scan_jsdoc_bare_package_specifier() {
1138 let imports = scan(" * @type {import('@scope/pkg').Client}");
1139 assert_eq!(imports.len(), 1);
1140 assert_eq!(imports[0].source, "@scope/pkg");
1141 assert_eq!(
1142 imports[0].imported_name,
1143 ImportedName::Named("Client".to_string())
1144 );
1145 }
1146
1147 #[test]
1148 fn scan_jsdoc_without_member_is_side_effect() {
1149 let imports = scan(" * @type {import('./types')}");
1150 assert_eq!(imports.len(), 1);
1151 assert_eq!(imports[0].source, "./types");
1152 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1153 assert!(imports[0].is_type_only);
1154 }
1155
1156 #[test]
1157 fn scan_jsdoc_empty_path_is_skipped() {
1158 let imports = scan(" * @type {import('').Foo}");
1159 assert!(imports.is_empty());
1160 }
1161
1162 #[test]
1163 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1164 let imports = scan(" * @type {import('./truncated");
1165 assert!(imports.is_empty());
1166 }
1167
1168 #[test]
1169 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1170 let imports = scan(" * @type {import('./types'.Foo}");
1171 assert!(imports.is_empty());
1172 }
1173
1174 #[test]
1175 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1176 let imports = scan(" * @type {import('./types') .Foo}");
1177 assert_eq!(imports.len(), 1);
1178 assert_eq!(imports[0].source, "./types");
1179 assert_eq!(
1180 imports[0].imported_name,
1181 ImportedName::Named("Foo".to_string())
1182 );
1183 }
1184
1185 #[test]
1186 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1187 let imports = scan(" * @type {import( './types').Foo}");
1188 assert_eq!(imports.len(), 1);
1189 assert_eq!(imports[0].source, "./types");
1190 }
1191
1192 #[test]
1193 fn scan_jsdoc_non_quote_after_paren_skipped() {
1194 let imports = scan(" * @type {import(foo).Bar}");
1195 assert!(imports.is_empty());
1196 }
1197
1198 #[test]
1199 fn scan_jsdoc_ignores_prose_with_import_word() {
1200 let imports = scan(" * This is an important note about imports.");
1201 assert!(imports.is_empty());
1202 }
1203
1204 #[test]
1205 fn scan_jsdoc_utf8_path_works() {
1206 let imports = scan(" * @type {import('./héllo').Foo}");
1207 assert_eq!(imports.len(), 1);
1208 assert_eq!(imports[0].source, "./héllo");
1209 }
1210
1211 #[test]
1212 fn scan_jsdoc_empty_body_is_empty() {
1213 assert!(scan("").is_empty());
1214 }
1215
1216 #[test]
1217 fn scan_jsdoc_no_import_in_body_is_empty() {
1218 assert!(scan(" * @param foo The foo parameter").is_empty());
1219 }
1220
1221 #[test]
1227 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1228 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1231 let imports = scan(body);
1232 assert!(
1233 imports.is_empty(),
1234 "prose import() should not be matched; got: {:?}",
1235 imports
1236 .iter()
1237 .map(|i| i.source.as_str())
1238 .collect::<Vec<_>>()
1239 );
1240 }
1241
1242 #[test]
1243 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1244 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1245 let imports = scan(body);
1246 assert!(
1247 imports.is_empty(),
1248 "object-literal example import() should not be matched; got: {:?}",
1249 imports
1250 .iter()
1251 .map(|i| i.source.as_str())
1252 .collect::<Vec<_>>()
1253 );
1254 }
1255
1256 #[test]
1257 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1258 let imports = scan(" * Use {import('./prose')} as an example string.");
1259 assert!(imports.is_empty());
1260 }
1261
1262 #[test]
1263 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1264 let imports = scan("\n * @example\n * { import('./prose') }\n");
1265 assert!(imports.is_empty());
1266 }
1267
1268 #[test]
1272 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1273 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1274 let imports = scan(body);
1275 assert_eq!(imports.len(), 1, "got: {imports:?}");
1276 assert_eq!(imports[0].source, "./real");
1277 assert_eq!(
1278 imports[0].imported_name,
1279 ImportedName::Named("Foo".to_string())
1280 );
1281 }
1282
1283 #[test]
1284 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1285 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1286 let imports = scan(body);
1287 assert_eq!(imports.len(), 1, "got: {imports:?}");
1288 assert_eq!(imports[0].source, "./real");
1289 assert_eq!(
1290 imports[0].imported_name,
1291 ImportedName::Named("Foo".to_string())
1292 );
1293 }
1294
1295 #[test]
1296 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1297 let body = "\n * @type\n * { import('./real').Foo }\n";
1298 let imports = scan(body);
1299 assert_eq!(imports.len(), 1, "got: {imports:?}");
1300 assert_eq!(imports[0].source, "./real");
1301 assert_eq!(
1302 imports[0].imported_name,
1303 ImportedName::Named("Foo".to_string())
1304 );
1305 }
1306
1307 #[test]
1308 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1309 let imports = scan(" * @satisfies {import('./real').Foo}");
1310 assert_eq!(imports.len(), 1, "got: {imports:?}");
1311 assert_eq!(imports[0].source, "./real");
1312 assert_eq!(
1313 imports[0].imported_name,
1314 ImportedName::Named("Foo".to_string())
1315 );
1316 }
1317
1318 #[test]
1319 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1320 let imports = scan(" * @template {import('./real').Foo} T");
1321 assert_eq!(imports.len(), 1, "got: {imports:?}");
1322 assert_eq!(imports[0].source, "./real");
1323 assert_eq!(
1324 imports[0].imported_name,
1325 ImportedName::Named("Foo".to_string())
1326 );
1327 }
1328
1329 #[test]
1330 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1331 let imports = scan(" * @enum {import('./real').Foo}");
1332 assert_eq!(imports.len(), 1, "got: {imports:?}");
1333 assert_eq!(imports[0].source, "./real");
1334 assert_eq!(
1335 imports[0].imported_name,
1336 ImportedName::Named("Foo".to_string())
1337 );
1338 }
1339
1340 #[test]
1341 fn scan_jsdoc_appends_to_existing_imports() {
1342 let mut imports = vec![ImportInfo {
1343 source: "existing".to_string(),
1344 imported_name: ImportedName::Default,
1345 local_name: "existing".to_string(),
1346 is_type_only: false,
1347 from_style: false,
1348 span: oxc_span::Span::default(),
1349 source_span: oxc_span::Span::default(),
1350 }];
1351 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1352 assert_eq!(imports.len(), 2);
1353 assert_eq!(imports[0].source, "existing");
1354 assert_eq!(imports[1].source, "./new");
1355 }
1356
1357 #[test]
1358 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1359 let imports = scan(" * @type {import('./t').Abc}");
1360 assert_eq!(imports.len(), 1);
1361 assert_eq!(
1362 imports[0].imported_name,
1363 ImportedName::Named("Abc".to_string())
1364 );
1365 }
1366
1367 #[test]
1368 fn scan_jsdoc_empty_member_name_is_skipped() {
1369 let imports = scan(" * @type {import('./x').}");
1370 assert!(imports.is_empty());
1371 }
1372}