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