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, Option<String>)> = 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, reason) = if has_public_tag(text) {
426 (VisibilityTag::Public, None)
427 } else if has_internal_tag(text) {
428 (VisibilityTag::Internal, None)
429 } else if has_alpha_tag(text) {
430 (VisibilityTag::Alpha, None)
431 } else if has_beta_tag(text) {
432 (VisibilityTag::Beta, None)
433 } else {
434 let (has_expected_unused, reason) = expected_unused_tag(text);
435 if has_expected_unused {
436 (VisibilityTag::ExpectedUnused, reason)
437 } else {
438 continue;
439 }
440 };
441 tag_offsets.push((comment.attached_to, tag, reason));
442 }
443 }
444 }
445
446 if tag_offsets.is_empty() {
447 return;
448 }
449
450 tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
451
452 for export in exports.iter_mut() {
453 if export.span.start == 0 && export.span.end == 0 {
454 continue;
455 }
456
457 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
458 export.visibility = tag_offsets[idx].1;
459 export
460 .expected_unused_reason
461 .clone_from(&tag_offsets[idx].2);
462 continue;
463 }
464
465 let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
466 if idx > 0 {
467 let (offset, tag, ref reason) = tag_offsets[idx - 1];
468 let offset = offset as usize;
469 let export_start = export.span.start as usize;
470 if offset < export_start && export_start <= source.len() {
471 let between = &source[offset..export_start];
472 if between.starts_with("export") && !between.contains(';') && !between.contains('}')
473 {
474 export.visibility = tag;
475 export.expected_unused_reason.clone_from(reason);
476 }
477 }
478 }
479 }
480}
481
482fn has_internal_tag(comment_text: &str) -> bool {
484 for (i, _) in comment_text.match_indices("@internal") {
485 let after = i + "@internal".len();
486 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
487 return true;
488 }
489 }
490 false
491}
492
493fn has_beta_tag(comment_text: &str) -> bool {
495 for (i, _) in comment_text.match_indices("@beta") {
496 let after = i + "@beta".len();
497 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
498 return true;
499 }
500 }
501 false
502}
503
504fn has_alpha_tag(comment_text: &str) -> bool {
506 for (i, _) in comment_text.match_indices("@alpha") {
507 let after = i + "@alpha".len();
508 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
509 return true;
510 }
511 }
512 false
513}
514
515fn split_jsdoc_reason(rest: &str) -> Option<String> {
516 for (idx, _) in rest.match_indices("--") {
517 let before_ok = idx == 0
518 || rest[..idx]
519 .chars()
520 .next_back()
521 .is_some_and(char::is_whitespace);
522 let after_idx = idx + 2;
523 let after_ok = after_idx == rest.len()
524 || rest[after_idx..]
525 .chars()
526 .next()
527 .is_some_and(char::is_whitespace);
528 if before_ok && after_ok {
529 let reason = rest[after_idx..].trim();
530 return if reason.is_empty() {
531 None
532 } else {
533 Some(reason.to_string())
534 };
535 }
536 }
537
538 None
539}
540
541fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
543 for (i, _) in comment_text.match_indices("@expected-unused") {
544 let after = i + "@expected-unused".len();
545 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
546 return (true, split_jsdoc_reason(&comment_text[after..]));
547 }
548 }
549 (false, None)
550}
551
552const fn is_ident_char(b: u8) -> bool {
554 b.is_ascii_alphanumeric() || b == b'_'
555}
556
557fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
581 if comments.is_empty() {
582 return;
583 }
584
585 for comment in comments {
586 if !comment.is_jsdoc() {
587 continue;
588 }
589 let content_span = comment.content_span();
590 let start = content_span.start as usize;
591 let end = (content_span.end as usize).min(source.len());
592 if start >= end {
593 continue;
594 }
595 scan_jsdoc_imports_in(&source[start..end], imports);
596 }
597}
598
599fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
607 let bytes = body.as_bytes();
608 let mut cursor = 0;
609 while let Some(rel) = body[cursor..].find("import(") {
610 let import_pos = cursor + rel;
611 if !is_inside_jsdoc_type_brace_group(bytes, import_pos) {
612 cursor = import_pos + "import(".len();
613 continue;
614 }
615 let open = import_pos + "import(".len();
616 cursor = open;
617 if open >= bytes.len() {
618 break;
619 }
620 let mut i = open;
621 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
622 i += 1;
623 }
624 if i >= bytes.len() {
625 break;
626 }
627 let quote = bytes[i];
628 if quote != b'\'' && quote != b'"' {
629 continue;
630 }
631 let path_start = i + 1;
632 let Some(rel_close) = body[path_start..].find(quote as char) else {
633 break;
634 };
635 let path_end = path_start + rel_close;
636 let path = &body[path_start..path_end];
637 if path.is_empty() {
638 cursor = path_end + 1;
639 continue;
640 }
641 let mut j = path_end + 1;
642 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
643 j += 1;
644 }
645 if j >= bytes.len() || bytes[j] != b')' {
646 cursor = path_end + 1;
647 continue;
648 }
649 j += 1;
650 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
651 j += 1;
652 }
653 cursor = j;
654 if j >= bytes.len() || bytes[j] != b'.' {
655 imports.push(ImportInfo {
656 source: path.to_string(),
657 imported_name: fallow_types::extract::ImportedName::SideEffect,
658 local_name: String::new(),
659 is_type_only: true,
660 from_style: false,
661 span: oxc_span::Span::default(),
662 source_span: oxc_span::Span::default(),
663 });
664 continue;
665 }
666 j += 1;
667 let name_start = j;
668 while j < bytes.len() && is_ident_char(bytes[j]) {
669 j += 1;
670 }
671 if name_start == j {
672 continue;
673 }
674 let member = &body[name_start..j];
675 cursor = j;
676 imports.push(ImportInfo {
677 source: path.to_string(),
678 imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
679 local_name: String::new(),
680 is_type_only: true,
681 from_style: false,
682 span: oxc_span::Span::default(),
683 source_span: oxc_span::Span::default(),
684 });
685 }
686}
687
688fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize) -> bool {
692 let Some(open_brace) = enclosing_jsdoc_brace_start(body, pos) else {
693 return false;
694 };
695
696 let prefix = line_prefix_before(body, open_brace);
697 if jsdoc_line_prefix_has_type_tag(prefix) {
698 return true;
699 }
700
701 strip_jsdoc_line_prefix(prefix).is_empty()
702 && preceding_jsdoc_line_has_type_tag(body, open_brace)
703 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
704}
705
706fn enclosing_jsdoc_brace_start(body: &[u8], pos: usize) -> Option<usize> {
707 let mut stack = Vec::new();
708 let limit = pos.min(body.len());
709 for (idx, &b) in body[..limit].iter().enumerate() {
710 match b {
711 b'{' => stack.push(idx),
712 b'}' => {
713 stack.pop();
714 }
715 _ => {}
716 }
717 }
718 stack.pop()
719}
720
721fn line_prefix_before(body: &[u8], pos: usize) -> &str {
722 let start = body[..pos]
723 .iter()
724 .rposition(|&b| b == b'\n')
725 .map_or(0, |idx| idx + 1);
726 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
727}
728
729fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
730 let trimmed = prefix.trim_start();
731 trimmed
732 .strip_prefix('*')
733 .map_or(trimmed, |rest| rest.trim_start())
734}
735
736fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
737 const TYPE_TAGS: [&str; 17] = [
738 "@arg",
739 "@argument",
740 "@augments",
741 "@callback",
742 "@enum",
743 "@extends",
744 "@implements",
745 "@param",
746 "@property",
747 "@prop",
748 "@return",
749 "@returns",
750 "@satisfies",
751 "@template",
752 "@this",
753 "@type",
754 "@typedef",
755 ];
756
757 let prefix = strip_jsdoc_line_prefix(prefix);
758 TYPE_TAGS
759 .iter()
760 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
761}
762
763fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
764 for (idx, _) in text.match_indices(tag) {
765 let after = idx + tag.len();
766 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
767 return true;
768 }
769 }
770 false
771}
772
773fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
774 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
775 return false;
776 };
777
778 let line_start = body[..line_end]
779 .iter()
780 .rposition(|&b| b == b'\n')
781 .map_or(0, |idx| idx + 1);
782
783 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
784}
785
786fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
787 let mut at_line_start = true;
788 let mut i = start.min(body.len());
789 let end = end.min(body.len());
790 while i < end {
791 match body[i] {
792 b'\n' => {
793 at_line_start = true;
794 i += 1;
795 }
796 b'\r' | b'\t' | b' ' => {
797 i += 1;
798 }
799 b'*' if at_line_start => {
800 at_line_start = false;
801 i += 1;
802 }
803 _ => return false,
804 }
805 }
806 true
807}
808
809fn has_public_tag(comment_text: &str) -> bool {
811 for (i, _) in comment_text.match_indices("@public") {
812 let after = i + "@public".len();
813 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
814 return true;
815 }
816 }
817 for (i, _) in comment_text.match_indices("@api") {
818 let after = i + "@api".len();
819 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
820 let rest = comment_text[after..].trim_start();
821 if rest.starts_with("public") {
822 let after_public = "public".len();
823 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
824 return true;
825 }
826 }
827 }
828 }
829 false
830}
831
832#[derive(Debug, Default, PartialEq, Eq)]
833pub struct ImportBindingUsage {
834 pub unused: Vec<String>,
835 pub type_referenced: Vec<String>,
836 pub value_referenced: Vec<String>,
837}
838
839#[derive(Debug, Default, PartialEq, Eq)]
840pub struct SemanticUsage {
841 pub import_binding_usage: ImportBindingUsage,
842 pub auto_import_candidates: Vec<String>,
843}
844
845pub fn compute_semantic_usage(
846 program: &Program<'_>,
847 imports: &[ImportInfo],
848 template_used: &rustc_hash::FxHashSet<String>,
849) -> SemanticUsage {
850 use oxc_semantic::SemanticBuilder;
851 use rustc_hash::FxHashSet;
852
853 let semantic_ret = SemanticBuilder::new().build(program);
854 let semantic = semantic_ret.semantic;
855 let scoping = semantic.scoping();
856 let root_scope = scoping.root_scope_id();
857
858 let mut unused = Vec::new();
859 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
860 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
861 for import in imports {
862 if import.local_name.is_empty() {
863 continue;
864 }
865 let name = oxc_str::Ident::from(import.local_name.as_str());
866 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
867 let mut has_references = false;
868 let mut has_type_references = false;
869 let mut has_value_references = false;
870
871 for reference in scoping.get_resolved_references(symbol_id) {
872 has_references = true;
873 has_type_references |= reference.is_type();
874 has_value_references |= reference.is_value();
875 }
876
877 if !has_references {
878 if !template_used.contains(&import.local_name) {
879 unused.push(import.local_name.clone());
880 }
881 continue;
882 }
883
884 if has_type_references {
885 type_referenced_bindings.insert(import.local_name.clone());
886 }
887 if has_value_references {
888 value_referenced_bindings.insert(import.local_name.clone());
889 }
890 }
891 }
892
893 unused.sort_unstable();
894
895 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
896 type_referenced_bindings.sort_unstable();
897
898 let mut value_referenced_bindings: Vec<String> =
899 value_referenced_bindings.into_iter().collect();
900 value_referenced_bindings.sort_unstable();
901
902 SemanticUsage {
903 import_binding_usage: ImportBindingUsage {
904 unused,
905 type_referenced: type_referenced_bindings,
906 value_referenced: value_referenced_bindings,
907 },
908 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
909 }
910}
911
912pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
913 use oxc_semantic::SemanticBuilder;
914
915 let semantic_ret = SemanticBuilder::new().build(program);
916 let semantic = semantic_ret.semantic;
917 compute_auto_import_candidates_from_semantic(semantic.scoping())
918}
919
920fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
921 use rustc_hash::FxHashSet;
922
923 let mut candidates: FxHashSet<String> = FxHashSet::default();
924 for (name, reference_ids) in scoping.root_unresolved_references() {
925 if reference_ids
926 .iter()
927 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
928 {
929 candidates.insert(name.as_str().to_string());
930 }
931 }
932
933 let mut candidates: Vec<String> = candidates.into_iter().collect();
934 candidates.sort_unstable();
935 candidates
936}
937
938pub fn compute_import_binding_usage(
955 program: &Program<'_>,
956 imports: &[ImportInfo],
957 template_used: &rustc_hash::FxHashSet<String>,
958) -> ImportBindingUsage {
959 compute_semantic_usage(program, imports, template_used).import_binding_usage
960}
961
962#[cfg(test)]
963mod tests {
964 use super::{
965 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
966 scan_jsdoc_imports_in,
967 };
968 use fallow_types::discover::FileId;
969 use fallow_types::extract::{ImportInfo, ImportedName};
970 use std::path::Path;
971
972 #[test]
973 fn has_public_tag_matches_bare_tag() {
974 assert!(has_public_tag(" * @public"));
975 }
976
977 #[test]
978 fn has_public_tag_matches_api_public_variant() {
979 assert!(has_public_tag(" * @api public"));
980 }
981
982 #[test]
983 fn has_public_tag_rejects_partial_word() {
984 assert!(!has_public_tag(" * @publicly"));
985 }
986
987 #[test]
988 fn has_public_tag_rejects_at_apipublic() {
989 assert!(!has_public_tag(" * @apipublic"));
990 }
991
992 #[test]
993 fn has_public_tag_rejects_missing_at() {
994 assert!(!has_public_tag(" * public"));
995 }
996
997 #[test]
998 fn has_internal_tag_matches_bare_tag() {
999 assert!(has_internal_tag(" * @internal"));
1000 }
1001
1002 #[test]
1003 fn has_internal_tag_rejects_partial_word() {
1004 assert!(!has_internal_tag(" * @internalizer"));
1005 }
1006
1007 #[test]
1008 fn has_internal_tag_rejects_missing_at() {
1009 assert!(!has_internal_tag(" * internal"));
1010 }
1011
1012 #[test]
1013 fn has_beta_tag_matches_bare_tag() {
1014 assert!(has_beta_tag(" * @beta"));
1015 }
1016
1017 #[test]
1018 fn has_beta_tag_rejects_partial_word() {
1019 assert!(!has_beta_tag(" * @betaware"));
1020 }
1021
1022 #[test]
1023 fn has_beta_tag_rejects_missing_at() {
1024 assert!(!has_beta_tag(" * beta"));
1025 }
1026
1027 #[test]
1028 fn alpha_tag_standalone() {
1029 assert!(has_alpha_tag("@alpha"));
1030 }
1031
1032 #[test]
1033 fn alpha_tag_with_text() {
1034 assert!(has_alpha_tag("@alpha Some description"));
1035 }
1036
1037 #[test]
1038 fn alpha_tag_not_prefix() {
1039 assert!(!has_alpha_tag("@alphabet"));
1040 }
1041
1042 #[test]
1043 fn has_alpha_tag_rejects_missing_at() {
1044 assert!(!has_alpha_tag(" * alpha"));
1045 }
1046
1047 fn scan(body: &str) -> Vec<ImportInfo> {
1048 let mut imports = Vec::new();
1049 scan_jsdoc_imports_in(body, &mut imports);
1050 imports
1051 }
1052
1053 #[test]
1054 fn scan_jsdoc_single_import_with_member() {
1055 let imports = scan(" * @param foo {import('./types').Foo}");
1056 assert_eq!(imports.len(), 1);
1057 assert_eq!(imports[0].source, "./types");
1058 assert_eq!(
1059 imports[0].imported_name,
1060 ImportedName::Named("Foo".to_string())
1061 );
1062 assert!(imports[0].is_type_only);
1063 assert!(imports[0].local_name.is_empty());
1064 }
1065
1066 #[test]
1067 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1068 let info = parse_source_to_module(
1069 FileId(0),
1070 Path::new("pages/index.ts"),
1071 r"
1072 useCounter();
1073 const price = formatPrice(10);
1074 const localOnly = () => null;
1075 localOnly();
1076 type Local = UseTypeOnly;
1077 ",
1078 0,
1079 false,
1080 );
1081
1082 assert!(
1083 info.auto_import_candidates
1084 .contains(&"formatPrice".to_string())
1085 );
1086 assert!(
1087 info.auto_import_candidates
1088 .contains(&"useCounter".to_string())
1089 );
1090 assert!(
1091 !info
1092 .auto_import_candidates
1093 .contains(&"UseTypeOnly".to_string())
1094 );
1095 assert!(
1096 !info
1097 .auto_import_candidates
1098 .contains(&"localOnly".to_string())
1099 );
1100 }
1101
1102 #[test]
1103 fn script_auto_import_candidates_skip_explicit_imports() {
1104 let info = parse_source_to_module(
1105 FileId(0),
1106 Path::new("pages/index.ts"),
1107 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1108 0,
1109 false,
1110 );
1111
1112 assert!(
1113 !info
1114 .auto_import_candidates
1115 .contains(&"useCounter".to_string())
1116 );
1117 assert!(
1118 info.auto_import_candidates
1119 .contains(&"useOther".to_string())
1120 );
1121 }
1122
1123 #[test]
1124 fn scan_jsdoc_double_quoted_path() {
1125 let imports = scan(r#" * @type {import("./types").Foo}"#);
1126 assert_eq!(imports.len(), 1);
1127 assert_eq!(imports[0].source, "./types");
1128 }
1129
1130 #[test]
1131 fn scan_jsdoc_multiple_imports_in_same_body() {
1132 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1133 assert_eq!(imports.len(), 2);
1134 assert_eq!(imports[0].source, "./a");
1135 assert_eq!(imports[1].source, "./b");
1136 }
1137
1138 #[test]
1139 fn scan_jsdoc_union_annotation_captures_both_members() {
1140 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1141 assert_eq!(imports.len(), 2);
1142 assert_eq!(
1143 imports[0].imported_name,
1144 ImportedName::Named("A".to_string())
1145 );
1146 assert_eq!(
1147 imports[1].imported_name,
1148 ImportedName::Named("B".to_string())
1149 );
1150 }
1151
1152 #[test]
1153 fn scan_jsdoc_nested_member_uses_first_segment() {
1154 let imports = scan(" * @type {import('./types').ns.Foo}");
1155 assert_eq!(imports.len(), 1);
1156 assert_eq!(
1157 imports[0].imported_name,
1158 ImportedName::Named("ns".to_string())
1159 );
1160 }
1161
1162 #[test]
1163 fn scan_jsdoc_parent_relative_path() {
1164 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1165 assert_eq!(imports.len(), 1);
1166 assert_eq!(imports[0].source, "../lib/types.js");
1167 }
1168
1169 #[test]
1170 fn scan_jsdoc_bare_package_specifier() {
1171 let imports = scan(" * @type {import('@scope/pkg').Client}");
1172 assert_eq!(imports.len(), 1);
1173 assert_eq!(imports[0].source, "@scope/pkg");
1174 assert_eq!(
1175 imports[0].imported_name,
1176 ImportedName::Named("Client".to_string())
1177 );
1178 }
1179
1180 #[test]
1181 fn scan_jsdoc_without_member_is_side_effect() {
1182 let imports = scan(" * @type {import('./types')}");
1183 assert_eq!(imports.len(), 1);
1184 assert_eq!(imports[0].source, "./types");
1185 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1186 assert!(imports[0].is_type_only);
1187 }
1188
1189 #[test]
1190 fn scan_jsdoc_empty_path_is_skipped() {
1191 let imports = scan(" * @type {import('').Foo}");
1192 assert!(imports.is_empty());
1193 }
1194
1195 #[test]
1196 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1197 let imports = scan(" * @type {import('./truncated");
1198 assert!(imports.is_empty());
1199 }
1200
1201 #[test]
1202 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1203 let imports = scan(" * @type {import('./types'.Foo}");
1204 assert!(imports.is_empty());
1205 }
1206
1207 #[test]
1208 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1209 let imports = scan(" * @type {import('./types') .Foo}");
1210 assert_eq!(imports.len(), 1);
1211 assert_eq!(imports[0].source, "./types");
1212 assert_eq!(
1213 imports[0].imported_name,
1214 ImportedName::Named("Foo".to_string())
1215 );
1216 }
1217
1218 #[test]
1219 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1220 let imports = scan(" * @type {import( './types').Foo}");
1221 assert_eq!(imports.len(), 1);
1222 assert_eq!(imports[0].source, "./types");
1223 }
1224
1225 #[test]
1226 fn scan_jsdoc_non_quote_after_paren_skipped() {
1227 let imports = scan(" * @type {import(foo).Bar}");
1228 assert!(imports.is_empty());
1229 }
1230
1231 #[test]
1232 fn scan_jsdoc_ignores_prose_with_import_word() {
1233 let imports = scan(" * This is an important note about imports.");
1234 assert!(imports.is_empty());
1235 }
1236
1237 #[test]
1238 fn scan_jsdoc_utf8_path_works() {
1239 let imports = scan(" * @type {import('./héllo').Foo}");
1240 assert_eq!(imports.len(), 1);
1241 assert_eq!(imports[0].source, "./héllo");
1242 }
1243
1244 #[test]
1245 fn scan_jsdoc_empty_body_is_empty() {
1246 assert!(scan("").is_empty());
1247 }
1248
1249 #[test]
1250 fn scan_jsdoc_no_import_in_body_is_empty() {
1251 assert!(scan(" * @param foo The foo parameter").is_empty());
1252 }
1253
1254 #[test]
1260 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1261 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1264 let imports = scan(body);
1265 assert!(
1266 imports.is_empty(),
1267 "prose import() should not be matched; got: {:?}",
1268 imports
1269 .iter()
1270 .map(|i| i.source.as_str())
1271 .collect::<Vec<_>>()
1272 );
1273 }
1274
1275 #[test]
1276 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1277 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1278 let imports = scan(body);
1279 assert!(
1280 imports.is_empty(),
1281 "object-literal example import() should not be matched; got: {:?}",
1282 imports
1283 .iter()
1284 .map(|i| i.source.as_str())
1285 .collect::<Vec<_>>()
1286 );
1287 }
1288
1289 #[test]
1290 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1291 let imports = scan(" * Use {import('./prose')} as an example string.");
1292 assert!(imports.is_empty());
1293 }
1294
1295 #[test]
1296 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1297 let imports = scan("\n * @example\n * { import('./prose') }\n");
1298 assert!(imports.is_empty());
1299 }
1300
1301 #[test]
1305 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1306 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1307 let imports = scan(body);
1308 assert_eq!(imports.len(), 1, "got: {imports:?}");
1309 assert_eq!(imports[0].source, "./real");
1310 assert_eq!(
1311 imports[0].imported_name,
1312 ImportedName::Named("Foo".to_string())
1313 );
1314 }
1315
1316 #[test]
1317 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1318 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1319 let imports = scan(body);
1320 assert_eq!(imports.len(), 1, "got: {imports:?}");
1321 assert_eq!(imports[0].source, "./real");
1322 assert_eq!(
1323 imports[0].imported_name,
1324 ImportedName::Named("Foo".to_string())
1325 );
1326 }
1327
1328 #[test]
1329 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1330 let body = "\n * @type\n * { import('./real').Foo }\n";
1331 let imports = scan(body);
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_satisfies_type_tag_is_still_matched() {
1342 let imports = scan(" * @satisfies {import('./real').Foo}");
1343 assert_eq!(imports.len(), 1, "got: {imports:?}");
1344 assert_eq!(imports[0].source, "./real");
1345 assert_eq!(
1346 imports[0].imported_name,
1347 ImportedName::Named("Foo".to_string())
1348 );
1349 }
1350
1351 #[test]
1352 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1353 let imports = scan(" * @template {import('./real').Foo} T");
1354 assert_eq!(imports.len(), 1, "got: {imports:?}");
1355 assert_eq!(imports[0].source, "./real");
1356 assert_eq!(
1357 imports[0].imported_name,
1358 ImportedName::Named("Foo".to_string())
1359 );
1360 }
1361
1362 #[test]
1363 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1364 let imports = scan(" * @enum {import('./real').Foo}");
1365 assert_eq!(imports.len(), 1, "got: {imports:?}");
1366 assert_eq!(imports[0].source, "./real");
1367 assert_eq!(
1368 imports[0].imported_name,
1369 ImportedName::Named("Foo".to_string())
1370 );
1371 }
1372
1373 #[test]
1374 fn scan_jsdoc_appends_to_existing_imports() {
1375 let mut imports = vec![ImportInfo {
1376 source: "existing".to_string(),
1377 imported_name: ImportedName::Default,
1378 local_name: "existing".to_string(),
1379 is_type_only: false,
1380 from_style: false,
1381 span: oxc_span::Span::default(),
1382 source_span: oxc_span::Span::default(),
1383 }];
1384 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1385 assert_eq!(imports.len(), 2);
1386 assert_eq!(imports[0].source, "existing");
1387 assert_eq!(imports[1].source, "./new");
1388 }
1389
1390 #[test]
1391 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1392 let imports = scan(" * @type {import('./t').Abc}");
1393 assert_eq!(imports.len(), 1);
1394 assert_eq!(
1395 imports[0].imported_name,
1396 ImportedName::Named("Abc".to_string())
1397 );
1398 }
1399
1400 #[test]
1401 fn scan_jsdoc_empty_member_name_is_skipped() {
1402 let imports = scan(" * @type {import('./x').}");
1403 assert!(imports.is_empty());
1404 }
1405}