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, mut semantic_usage) =
106 build_primary_extractor(&parser_return.program, path, source, source_type);
107
108 let line_offsets = fallow_types::extract::compute_line_offsets(source);
109
110 let (mut complexity, mut flag_uses) = compute_primary_complexity_and_flags(
111 &parser_return.program,
112 parser_source,
113 &extractor.inline_template_findings,
114 &line_offsets,
115 need_complexity,
116 );
117
118 apply_jsx_retry_or_jsdoc(
119 &JsxRetryOrJsdocInput {
120 path,
121 parser_source,
122 source_type,
123 need_complexity,
124 line_offsets: &line_offsets,
125 comments: &parser_return.program.comments,
126 source,
127 },
128 &mut ParseOutputs {
129 extractor: &mut extractor,
130 semantic_usage: &mut semantic_usage,
131 complexity: &mut complexity,
132 flag_uses: &mut flag_uses,
133 parsed_suppressions: &mut parsed_suppressions,
134 },
135 );
136
137 assemble_module_info(ModuleAssemblyInput {
138 extractor,
139 file_id,
140 content_hash,
141 parsed_suppressions,
142 semantic_usage,
143 line_offsets,
144 complexity,
145 flag_uses,
146 })
147}
148
149struct JsxRetryOrJsdocInput<'a> {
151 path: &'a Path,
152 parser_source: &'a str,
153 source_type: SourceType,
154 need_complexity: bool,
155 line_offsets: &'a [u32],
156 comments: &'a [Comment],
157 source: &'a str,
158}
159
160struct ModuleAssemblyInput {
161 extractor: ModuleInfoExtractor,
162 file_id: FileId,
163 content_hash: u64,
164 parsed_suppressions: crate::suppress::ParsedSuppressions,
165 semantic_usage: SemanticUsage,
166 line_offsets: Vec<u32>,
167 complexity: Vec<FunctionComplexity>,
168 flag_uses: Vec<FlagUse>,
169}
170
171fn build_primary_extractor(
174 program: &Program<'_>,
175 path: &Path,
176 source: &str,
177 source_type: SourceType,
178) -> (ModuleInfoExtractor, SemanticUsage) {
179 let mut extractor = ModuleInfoExtractor::new();
180 extractor.jsx_capable = source_type.is_jsx();
184 extractor.visit_program(program);
185 extractor.resolve_pending_local_export_specifiers();
186
187 let template_used_imports =
188 collect_glimmer_template_into_extractor(&mut extractor, path, source);
189 let semantic_usage =
190 compute_semantic_usage(program, &extractor.imports, &template_used_imports);
191 (extractor, semantic_usage)
192}
193
194fn compute_primary_complexity_and_flags(
197 program: &Program<'_>,
198 parser_source: &str,
199 inline_template_findings: &[crate::visitor::InlineTemplateFinding],
200 line_offsets: &[u32],
201 need_complexity: bool,
202) -> (Vec<FunctionComplexity>, Vec<FlagUse>) {
203 let mut complexity = if need_complexity {
204 crate::complexity::compute_complexity(program, parser_source, line_offsets)
205 } else {
206 Vec::new()
207 };
208 if need_complexity {
209 append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
210 }
211
212 let flag_uses = crate::flags::extract_flags(
213 program,
214 line_offsets,
215 &[], &[], false, );
219 (complexity, flag_uses)
220}
221
222struct ParseOutputs<'a> {
224 extractor: &'a mut ModuleInfoExtractor,
225 semantic_usage: &'a mut SemanticUsage,
226 complexity: &'a mut Vec<FunctionComplexity>,
227 flag_uses: &'a mut Vec<FlagUse>,
228 parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
229}
230
231fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
235 let retry_input = JsxRetryInput {
236 path: input.path,
237 source: input.source,
238 parser_source: input.parser_source,
239 source_type: input.source_type,
240 total_extracted: outputs.extractor.exports.len()
241 + outputs.extractor.imports.len()
242 + outputs.extractor.re_exports.len(),
243 need_complexity: input.need_complexity,
244 line_offsets: input.line_offsets,
245 };
246 let Some(retry) = parse_with_jsx_retry(&retry_input) else {
247 apply_jsdoc_tags_to_extractor(&mut *outputs.extractor, input.comments, input.source);
248 return;
249 };
250 *outputs.extractor = retry.extractor;
251 *outputs.semantic_usage = retry.semantic_usage;
252 *outputs.complexity = retry.complexity;
253 *outputs.flag_uses = retry.flag_uses;
254 *outputs.parsed_suppressions = retry.parsed_suppressions;
255}
256
257fn apply_jsdoc_tags_to_extractor(
260 extractor: &mut ModuleInfoExtractor,
261 comments: &[Comment],
262 source: &str,
263) {
264 apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source);
265 extract_jsdoc_import_types(&mut extractor.imports, comments, source);
266}
267
268fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
271 let ModuleAssemblyInput {
272 extractor,
273 file_id,
274 content_hash,
275 parsed_suppressions,
276 semantic_usage,
277 line_offsets,
278 complexity,
279 flag_uses,
280 } = input;
281 let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
282 info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
283 info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
284 info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
285 info.auto_import_candidates = semantic_usage.auto_import_candidates;
286 info.line_offsets = line_offsets;
287 info.complexity = complexity;
288 info.flag_uses = flag_uses;
289 info
290}
291
292struct JsxRetryInput<'a> {
293 path: &'a Path,
294 source: &'a str,
295 parser_source: &'a str,
296 source_type: SourceType,
297 total_extracted: usize,
298 need_complexity: bool,
299 line_offsets: &'a [u32],
300}
301
302fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
303 if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
304 return None;
305 }
306
307 let jsx_type = if input.source_type.is_typescript() {
308 SourceType::tsx()
309 } else {
310 SourceType::jsx()
311 };
312 let allocator = Allocator::default();
313 let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
314 let mut extractor = ModuleInfoExtractor::new();
315 extractor.jsx_capable = true;
318 extractor.visit_program(&retry_return.program);
319 extractor.resolve_pending_local_export_specifiers();
320 let retry_total =
321 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
322 if retry_total <= input.total_extracted {
323 return None;
324 }
325
326 let template_used_imports =
327 collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
328 let semantic_usage = compute_semantic_usage(
329 &retry_return.program,
330 &extractor.imports,
331 &template_used_imports,
332 );
333 let complexity = retry_complexity(
334 input.need_complexity,
335 &retry_return.program,
336 input.parser_source,
337 input.line_offsets,
338 &extractor,
339 );
340 let flag_uses =
341 crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
342 let parsed_suppressions =
343 crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
344 apply_jsdoc_visibility_tags(
345 &mut extractor.exports,
346 &retry_return.program.comments,
347 input.source,
348 );
349 extract_jsdoc_import_types(
350 &mut extractor.imports,
351 &retry_return.program.comments,
352 input.source,
353 );
354 Some(JsxRetryParse {
355 extractor,
356 semantic_usage,
357 complexity,
358 flag_uses,
359 parsed_suppressions,
360 })
361}
362
363fn retry_complexity(
364 need_complexity: bool,
365 program: &Program<'_>,
366 parser_source: &str,
367 line_offsets: &[u32],
368 extractor: &ModuleInfoExtractor,
369) -> Vec<FunctionComplexity> {
370 if !need_complexity {
371 return Vec::new();
372 }
373 let mut complexity =
374 crate::complexity::compute_complexity(program, parser_source, line_offsets);
375 append_inline_template_complexity(
376 &mut complexity,
377 &extractor.inline_template_findings,
378 line_offsets,
379 );
380 complexity
381}
382
383fn parse_non_js_source_to_module(
384 file_id: FileId,
385 path: &Path,
386 source: &str,
387 content_hash: u64,
388 need_complexity: bool,
389) -> Option<ModuleInfo> {
390 if is_sfc_file(path) {
391 return Some(parse_sfc_to_module(
392 file_id,
393 path,
394 source,
395 content_hash,
396 need_complexity,
397 ));
398 }
399 if is_astro_file(path) {
400 return Some(parse_astro_to_module(
401 file_id,
402 source,
403 content_hash,
404 need_complexity,
405 ));
406 }
407 if is_mdx_file(path) {
408 return Some(parse_mdx_to_module(file_id, source, content_hash));
409 }
410 if is_css_file(path) {
411 return Some(parse_css_to_module(file_id, path, source, content_hash));
412 }
413 if is_graphql_file(path) {
414 return Some(parse_graphql_to_module(file_id, source, content_hash));
415 }
416 if is_html_file(path) {
417 return Some(parse_html_to_module_with_complexity(
418 file_id,
419 source,
420 content_hash,
421 need_complexity,
422 ));
423 }
424 None
425}
426
427fn collect_glimmer_template_into_extractor(
449 extractor: &mut ModuleInfoExtractor,
450 path: &Path,
451 source: &str,
452) -> rustc_hash::FxHashSet<String> {
453 use rustc_hash::FxHashSet;
454
455 if !is_glimmer_file(path) {
456 return FxHashSet::default();
457 }
458 let template_ranges = crate::glimmer::find_template_ranges(source);
459 if template_ranges.is_empty() {
460 return FxHashSet::default();
461 }
462
463 let imported_bindings: FxHashSet<String> = extractor
464 .imports
465 .iter()
466 .filter(|import| !import.local_name.is_empty())
467 .map(|import| import.local_name.clone())
468 .collect();
469
470 let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
471 source,
472 &template_ranges,
473 &imported_bindings,
474 );
475 extractor.member_accesses.extend(usage.member_accesses);
476 usage.used_bindings
477}
478
479fn append_inline_template_complexity(
490 complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
491 findings: &[crate::visitor::InlineTemplateFinding],
492 line_offsets: &[u32],
493) {
494 for finding in findings {
495 let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
496 &finding.template_source,
497 ) else {
498 continue;
499 };
500 let (line, col) =
501 fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
502 fc.line = line;
503 fc.col = col;
504 complexity.push(fc);
505 }
506}
507
508fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
518 if exports.is_empty() || comments.is_empty() {
519 return;
520 }
521
522 let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
523 if tag_offsets.is_empty() {
524 return;
525 }
526 tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
527
528 for export in exports.iter_mut() {
529 apply_visibility_tag_to_export(export, &tag_offsets, source);
530 }
531}
532
533fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
536 if has_public_tag(text) {
537 Some((VisibilityTag::Public, None))
538 } else if has_internal_tag(text) {
539 Some((VisibilityTag::Internal, None))
540 } else if has_alpha_tag(text) {
541 Some((VisibilityTag::Alpha, None))
542 } else if has_beta_tag(text) {
543 Some((VisibilityTag::Beta, None))
544 } else {
545 let (has_expected_unused, reason) = expected_unused_tag(text);
546 has_expected_unused.then_some((VisibilityTag::ExpectedUnused, reason))
547 }
548}
549
550fn collect_jsdoc_tag_offsets(
553 comments: &[Comment],
554 source: &str,
555) -> Vec<(u32, VisibilityTag, Option<String>)> {
556 let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
557 for comment in comments {
558 if !comment.is_jsdoc() {
559 continue;
560 }
561 let content_span = comment.content_span();
562 let start = content_span.start as usize;
563 let end = (content_span.end as usize).min(source.len());
564 if start >= end {
565 continue;
566 }
567 if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
568 tag_offsets.push((comment.attached_to, tag, reason));
569 }
570 }
571 tag_offsets
572}
573
574fn apply_visibility_tag_to_export(
578 export: &mut ExportInfo,
579 tag_offsets: &[(u32, VisibilityTag, Option<String>)],
580 source: &str,
581) {
582 if export.span.start == 0 && export.span.end == 0 {
583 return;
584 }
585
586 if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
587 export.visibility = tag_offsets[idx].1;
588 export
589 .expected_unused_reason
590 .clone_from(&tag_offsets[idx].2);
591 return;
592 }
593
594 let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
595 if idx > 0 {
596 let (offset, tag, ref reason) = tag_offsets[idx - 1];
597 let offset = offset as usize;
598 let export_start = export.span.start as usize;
599 if offset < export_start && export_start <= source.len() {
600 let between = &source[offset..export_start];
601 if between.starts_with("export") && !between.contains(';') && !between.contains('}') {
602 export.visibility = tag;
603 export.expected_unused_reason.clone_from(reason);
604 }
605 }
606 }
607}
608
609fn has_internal_tag(comment_text: &str) -> bool {
611 for (i, _) in comment_text.match_indices("@internal") {
612 let after = i + "@internal".len();
613 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
614 return true;
615 }
616 }
617 false
618}
619
620fn has_beta_tag(comment_text: &str) -> bool {
622 for (i, _) in comment_text.match_indices("@beta") {
623 let after = i + "@beta".len();
624 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
625 return true;
626 }
627 }
628 false
629}
630
631fn has_alpha_tag(comment_text: &str) -> bool {
633 for (i, _) in comment_text.match_indices("@alpha") {
634 let after = i + "@alpha".len();
635 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
636 return true;
637 }
638 }
639 false
640}
641
642fn split_jsdoc_reason(rest: &str) -> Option<String> {
643 for (idx, _) in rest.match_indices("--") {
644 let before_ok = idx == 0
645 || rest[..idx]
646 .chars()
647 .next_back()
648 .is_some_and(char::is_whitespace);
649 let after_idx = idx + 2;
650 let after_ok = after_idx == rest.len()
651 || rest[after_idx..]
652 .chars()
653 .next()
654 .is_some_and(char::is_whitespace);
655 if before_ok && after_ok {
656 let reason = rest[after_idx..].trim();
657 return if reason.is_empty() {
658 None
659 } else {
660 Some(reason.to_string())
661 };
662 }
663 }
664
665 None
666}
667
668fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
670 for (i, _) in comment_text.match_indices("@expected-unused") {
671 let after = i + "@expected-unused".len();
672 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
673 return (true, split_jsdoc_reason(&comment_text[after..]));
674 }
675 }
676 (false, None)
677}
678
679const fn is_ident_char(b: u8) -> bool {
681 b.is_ascii_alphanumeric() || b == b'_'
682}
683
684fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
708 if comments.is_empty() {
709 return;
710 }
711
712 for comment in comments {
713 if !comment.is_jsdoc() {
714 continue;
715 }
716 let content_span = comment.content_span();
717 let start = content_span.start as usize;
718 let end = (content_span.end as usize).min(source.len());
719 if start >= end {
720 continue;
721 }
722 scan_jsdoc_imports_in(&source[start..end], imports);
723 }
724}
725
726fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
734 let bytes = body.as_bytes();
735 let mut cursor = 0;
736 while let Some(rel) = body[cursor..].find("import(") {
737 let import_pos = cursor + rel;
738 if !is_inside_jsdoc_type_brace_group(bytes, import_pos) {
739 cursor = import_pos + "import(".len();
740 continue;
741 }
742 let open = import_pos + "import(".len();
743 match locate_jsdoc_import_path(body, bytes, open) {
744 JsdocImportScan::Stop => break,
745 JsdocImportScan::Skip(next) => {
746 cursor = next;
747 }
748 JsdocImportScan::Found { path, after_paren } => {
749 cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
750 }
751 }
752 }
753}
754
755enum JsdocImportScan<'a> {
758 Stop,
760 Skip(usize),
762 Found { path: &'a str, after_paren: usize },
764}
765
766fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
769 if open >= bytes.len() {
770 return JsdocImportScan::Stop;
771 }
772 let mut i = open;
773 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
774 i += 1;
775 }
776 if i >= bytes.len() {
777 return JsdocImportScan::Stop;
778 }
779 let quote = bytes[i];
780 if quote != b'\'' && quote != b'"' {
781 return JsdocImportScan::Skip(open);
782 }
783 let path_start = i + 1;
784 let Some(rel_close) = body[path_start..].find(quote as char) else {
785 return JsdocImportScan::Stop;
786 };
787 let path_end = path_start + rel_close;
788 let path = &body[path_start..path_end];
789 if path.is_empty() {
790 return JsdocImportScan::Skip(path_end + 1);
791 }
792 let mut j = path_end + 1;
793 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
794 j += 1;
795 }
796 if j >= bytes.len() || bytes[j] != b')' {
797 return JsdocImportScan::Skip(path_end + 1);
798 }
799 j += 1;
800 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
801 j += 1;
802 }
803 JsdocImportScan::Found {
804 path,
805 after_paren: j,
806 }
807}
808
809fn resolve_jsdoc_import(
812 body: &str,
813 bytes: &[u8],
814 after_paren: usize,
815 path: &str,
816 imports: &mut Vec<ImportInfo>,
817) -> usize {
818 let mut j = after_paren;
819 if j >= bytes.len() || bytes[j] != b'.' {
820 imports.push(jsdoc_type_import(
821 path,
822 fallow_types::extract::ImportedName::SideEffect,
823 ));
824 return after_paren;
825 }
826 j += 1;
827 let name_start = j;
828 while j < bytes.len() && is_ident_char(bytes[j]) {
829 j += 1;
830 }
831 if name_start == j {
832 return after_paren;
835 }
836 let member = &body[name_start..j];
837 imports.push(jsdoc_type_import(
838 path,
839 fallow_types::extract::ImportedName::Named(member.to_string()),
840 ));
841 j
842}
843
844fn jsdoc_type_import(
847 source: &str,
848 imported_name: fallow_types::extract::ImportedName,
849) -> ImportInfo {
850 ImportInfo {
851 source: source.to_string(),
852 imported_name,
853 local_name: String::new(),
854 is_type_only: true,
855 from_style: false,
856 span: oxc_span::Span::default(),
857 source_span: oxc_span::Span::default(),
858 }
859}
860
861fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize) -> bool {
865 let Some(open_brace) = enclosing_jsdoc_brace_start(body, pos) else {
866 return false;
867 };
868
869 let prefix = line_prefix_before(body, open_brace);
870 if jsdoc_line_prefix_has_type_tag(prefix) {
871 return true;
872 }
873
874 strip_jsdoc_line_prefix(prefix).is_empty()
875 && preceding_jsdoc_line_has_type_tag(body, open_brace)
876 && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
877}
878
879fn enclosing_jsdoc_brace_start(body: &[u8], pos: usize) -> Option<usize> {
880 let mut stack = Vec::new();
881 let limit = pos.min(body.len());
882 for (idx, &b) in body[..limit].iter().enumerate() {
883 match b {
884 b'{' => stack.push(idx),
885 b'}' => {
886 stack.pop();
887 }
888 _ => {}
889 }
890 }
891 stack.pop()
892}
893
894fn line_prefix_before(body: &[u8], pos: usize) -> &str {
895 let start = body[..pos]
896 .iter()
897 .rposition(|&b| b == b'\n')
898 .map_or(0, |idx| idx + 1);
899 std::str::from_utf8(&body[start..pos]).unwrap_or_default()
900}
901
902fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
903 let trimmed = prefix.trim_start();
904 trimmed
905 .strip_prefix('*')
906 .map_or(trimmed, |rest| rest.trim_start())
907}
908
909fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
910 const TYPE_TAGS: [&str; 17] = [
911 "@arg",
912 "@argument",
913 "@augments",
914 "@callback",
915 "@enum",
916 "@extends",
917 "@implements",
918 "@param",
919 "@property",
920 "@prop",
921 "@return",
922 "@returns",
923 "@satisfies",
924 "@template",
925 "@this",
926 "@type",
927 "@typedef",
928 ];
929
930 let prefix = strip_jsdoc_line_prefix(prefix);
931 TYPE_TAGS
932 .iter()
933 .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
934}
935
936fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
937 for (idx, _) in text.match_indices(tag) {
938 let after = idx + tag.len();
939 if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
940 return true;
941 }
942 }
943 false
944}
945
946fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
947 let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
948 return false;
949 };
950
951 let line_start = body[..line_end]
952 .iter()
953 .rposition(|&b| b == b'\n')
954 .map_or(0, |idx| idx + 1);
955
956 std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
957}
958
959fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
960 let mut at_line_start = true;
961 let mut i = start.min(body.len());
962 let end = end.min(body.len());
963 while i < end {
964 match body[i] {
965 b'\n' => {
966 at_line_start = true;
967 i += 1;
968 }
969 b'\r' | b'\t' | b' ' => {
970 i += 1;
971 }
972 b'*' if at_line_start => {
973 at_line_start = false;
974 i += 1;
975 }
976 _ => return false,
977 }
978 }
979 true
980}
981
982fn has_public_tag(comment_text: &str) -> bool {
984 for (i, _) in comment_text.match_indices("@public") {
985 let after = i + "@public".len();
986 if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
987 return true;
988 }
989 }
990 for (i, _) in comment_text.match_indices("@api") {
991 let after = i + "@api".len();
992 if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
993 let rest = comment_text[after..].trim_start();
994 if rest.starts_with("public") {
995 let after_public = "public".len();
996 if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
997 return true;
998 }
999 }
1000 }
1001 }
1002 false
1003}
1004
1005#[derive(Debug, Default, PartialEq, Eq)]
1006pub struct ImportBindingUsage {
1007 pub unused: Vec<String>,
1008 pub type_referenced: Vec<String>,
1009 pub value_referenced: Vec<String>,
1010}
1011
1012#[derive(Debug, Default, PartialEq, Eq)]
1013pub struct SemanticUsage {
1014 pub import_binding_usage: ImportBindingUsage,
1015 pub auto_import_candidates: Vec<String>,
1016}
1017
1018pub fn compute_semantic_usage(
1019 program: &Program<'_>,
1020 imports: &[ImportInfo],
1021 template_used: &rustc_hash::FxHashSet<String>,
1022) -> SemanticUsage {
1023 use oxc_semantic::SemanticBuilder;
1024 use rustc_hash::FxHashSet;
1025
1026 let semantic_ret = SemanticBuilder::new().build(program);
1027 let semantic = semantic_ret.semantic;
1028 let scoping = semantic.scoping();
1029 let root_scope = scoping.root_scope_id();
1030
1031 let mut unused = Vec::new();
1032 let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1033 let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1034 for import in imports {
1035 if import.local_name.is_empty() {
1036 continue;
1037 }
1038 let name = oxc_str::Ident::from(import.local_name.as_str());
1039 if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1040 let mut has_references = false;
1041 let mut has_type_references = false;
1042 let mut has_value_references = false;
1043
1044 for reference in scoping.get_resolved_references(symbol_id) {
1045 has_references = true;
1046 has_type_references |= reference.is_type();
1047 has_value_references |= reference.is_value();
1048 }
1049
1050 if !has_references {
1051 if !template_used.contains(&import.local_name) {
1052 unused.push(import.local_name.clone());
1053 }
1054 continue;
1055 }
1056
1057 if has_type_references {
1058 type_referenced_bindings.insert(import.local_name.clone());
1059 }
1060 if has_value_references {
1061 value_referenced_bindings.insert(import.local_name.clone());
1062 }
1063 }
1064 }
1065
1066 unused.sort_unstable();
1067
1068 let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1069 type_referenced_bindings.sort_unstable();
1070
1071 let mut value_referenced_bindings: Vec<String> =
1072 value_referenced_bindings.into_iter().collect();
1073 value_referenced_bindings.sort_unstable();
1074
1075 SemanticUsage {
1076 import_binding_usage: ImportBindingUsage {
1077 unused,
1078 type_referenced: type_referenced_bindings,
1079 value_referenced: value_referenced_bindings,
1080 },
1081 auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1082 }
1083}
1084
1085pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
1086 use oxc_semantic::SemanticBuilder;
1087
1088 let semantic_ret = SemanticBuilder::new().build(program);
1089 let semantic = semantic_ret.semantic;
1090 compute_auto_import_candidates_from_semantic(semantic.scoping())
1091}
1092
1093fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1094 use rustc_hash::FxHashSet;
1095
1096 let mut candidates: FxHashSet<String> = FxHashSet::default();
1097 for (name, reference_ids) in scoping.root_unresolved_references() {
1098 if reference_ids
1099 .iter()
1100 .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1101 {
1102 candidates.insert(name.as_str().to_string());
1103 }
1104 }
1105
1106 let mut candidates: Vec<String> = candidates.into_iter().collect();
1107 candidates.sort_unstable();
1108 candidates
1109}
1110
1111pub fn compute_import_binding_usage(
1128 program: &Program<'_>,
1129 imports: &[ImportInfo],
1130 template_used: &rustc_hash::FxHashSet<String>,
1131) -> ImportBindingUsage {
1132 compute_semantic_usage(program, imports, template_used).import_binding_usage
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 use super::{
1138 has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
1139 scan_jsdoc_imports_in,
1140 };
1141 use fallow_types::discover::FileId;
1142 use fallow_types::extract::{ImportInfo, ImportedName};
1143 use std::path::Path;
1144
1145 #[test]
1146 fn has_public_tag_matches_bare_tag() {
1147 assert!(has_public_tag(" * @public"));
1148 }
1149
1150 #[test]
1151 fn has_public_tag_matches_api_public_variant() {
1152 assert!(has_public_tag(" * @api public"));
1153 }
1154
1155 #[test]
1156 fn has_public_tag_rejects_partial_word() {
1157 assert!(!has_public_tag(" * @publicly"));
1158 }
1159
1160 #[test]
1161 fn has_public_tag_rejects_at_apipublic() {
1162 assert!(!has_public_tag(" * @apipublic"));
1163 }
1164
1165 #[test]
1166 fn has_public_tag_rejects_missing_at() {
1167 assert!(!has_public_tag(" * public"));
1168 }
1169
1170 #[test]
1171 fn has_internal_tag_matches_bare_tag() {
1172 assert!(has_internal_tag(" * @internal"));
1173 }
1174
1175 #[test]
1176 fn has_internal_tag_rejects_partial_word() {
1177 assert!(!has_internal_tag(" * @internalizer"));
1178 }
1179
1180 #[test]
1181 fn has_internal_tag_rejects_missing_at() {
1182 assert!(!has_internal_tag(" * internal"));
1183 }
1184
1185 #[test]
1186 fn has_beta_tag_matches_bare_tag() {
1187 assert!(has_beta_tag(" * @beta"));
1188 }
1189
1190 #[test]
1191 fn has_beta_tag_rejects_partial_word() {
1192 assert!(!has_beta_tag(" * @betaware"));
1193 }
1194
1195 #[test]
1196 fn has_beta_tag_rejects_missing_at() {
1197 assert!(!has_beta_tag(" * beta"));
1198 }
1199
1200 #[test]
1201 fn alpha_tag_standalone() {
1202 assert!(has_alpha_tag("@alpha"));
1203 }
1204
1205 #[test]
1206 fn alpha_tag_with_text() {
1207 assert!(has_alpha_tag("@alpha Some description"));
1208 }
1209
1210 #[test]
1211 fn alpha_tag_not_prefix() {
1212 assert!(!has_alpha_tag("@alphabet"));
1213 }
1214
1215 #[test]
1216 fn has_alpha_tag_rejects_missing_at() {
1217 assert!(!has_alpha_tag(" * alpha"));
1218 }
1219
1220 fn scan(body: &str) -> Vec<ImportInfo> {
1221 let mut imports = Vec::new();
1222 scan_jsdoc_imports_in(body, &mut imports);
1223 imports
1224 }
1225
1226 #[test]
1227 fn scan_jsdoc_single_import_with_member() {
1228 let imports = scan(" * @param foo {import('./types').Foo}");
1229 assert_eq!(imports.len(), 1);
1230 assert_eq!(imports[0].source, "./types");
1231 assert_eq!(
1232 imports[0].imported_name,
1233 ImportedName::Named("Foo".to_string())
1234 );
1235 assert!(imports[0].is_type_only);
1236 assert!(imports[0].local_name.is_empty());
1237 }
1238
1239 #[test]
1240 fn script_auto_import_candidates_capture_zero_import_value_refs() {
1241 let info = parse_source_to_module(
1242 FileId(0),
1243 Path::new("pages/index.ts"),
1244 r"
1245 useCounter();
1246 const price = formatPrice(10);
1247 const localOnly = () => null;
1248 localOnly();
1249 type Local = UseTypeOnly;
1250 ",
1251 0,
1252 false,
1253 );
1254
1255 assert!(
1256 info.auto_import_candidates
1257 .contains(&"formatPrice".to_string())
1258 );
1259 assert!(
1260 info.auto_import_candidates
1261 .contains(&"useCounter".to_string())
1262 );
1263 assert!(
1264 !info
1265 .auto_import_candidates
1266 .contains(&"UseTypeOnly".to_string())
1267 );
1268 assert!(
1269 !info
1270 .auto_import_candidates
1271 .contains(&"localOnly".to_string())
1272 );
1273 }
1274
1275 #[test]
1276 fn script_auto_import_candidates_skip_explicit_imports() {
1277 let info = parse_source_to_module(
1278 FileId(0),
1279 Path::new("pages/index.ts"),
1280 "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1281 0,
1282 false,
1283 );
1284
1285 assert!(
1286 !info
1287 .auto_import_candidates
1288 .contains(&"useCounter".to_string())
1289 );
1290 assert!(
1291 info.auto_import_candidates
1292 .contains(&"useOther".to_string())
1293 );
1294 }
1295
1296 #[test]
1297 fn scan_jsdoc_double_quoted_path() {
1298 let imports = scan(r#" * @type {import("./types").Foo}"#);
1299 assert_eq!(imports.len(), 1);
1300 assert_eq!(imports[0].source, "./types");
1301 }
1302
1303 #[test]
1304 fn scan_jsdoc_multiple_imports_in_same_body() {
1305 let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1306 assert_eq!(imports.len(), 2);
1307 assert_eq!(imports[0].source, "./a");
1308 assert_eq!(imports[1].source, "./b");
1309 }
1310
1311 #[test]
1312 fn scan_jsdoc_union_annotation_captures_both_members() {
1313 let imports = scan(" * @type {import('./a').A | import('./b').B}");
1314 assert_eq!(imports.len(), 2);
1315 assert_eq!(
1316 imports[0].imported_name,
1317 ImportedName::Named("A".to_string())
1318 );
1319 assert_eq!(
1320 imports[1].imported_name,
1321 ImportedName::Named("B".to_string())
1322 );
1323 }
1324
1325 #[test]
1326 fn scan_jsdoc_nested_member_uses_first_segment() {
1327 let imports = scan(" * @type {import('./types').ns.Foo}");
1328 assert_eq!(imports.len(), 1);
1329 assert_eq!(
1330 imports[0].imported_name,
1331 ImportedName::Named("ns".to_string())
1332 );
1333 }
1334
1335 #[test]
1336 fn scan_jsdoc_parent_relative_path() {
1337 let imports = scan(" * @type {import('../lib/types.js').Foo}");
1338 assert_eq!(imports.len(), 1);
1339 assert_eq!(imports[0].source, "../lib/types.js");
1340 }
1341
1342 #[test]
1343 fn scan_jsdoc_bare_package_specifier() {
1344 let imports = scan(" * @type {import('@scope/pkg').Client}");
1345 assert_eq!(imports.len(), 1);
1346 assert_eq!(imports[0].source, "@scope/pkg");
1347 assert_eq!(
1348 imports[0].imported_name,
1349 ImportedName::Named("Client".to_string())
1350 );
1351 }
1352
1353 #[test]
1354 fn scan_jsdoc_without_member_is_side_effect() {
1355 let imports = scan(" * @type {import('./types')}");
1356 assert_eq!(imports.len(), 1);
1357 assert_eq!(imports[0].source, "./types");
1358 assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1359 assert!(imports[0].is_type_only);
1360 }
1361
1362 #[test]
1363 fn scan_jsdoc_empty_path_is_skipped() {
1364 let imports = scan(" * @type {import('').Foo}");
1365 assert!(imports.is_empty());
1366 }
1367
1368 #[test]
1369 fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1370 let imports = scan(" * @type {import('./truncated");
1371 assert!(imports.is_empty());
1372 }
1373
1374 #[test]
1375 fn scan_jsdoc_missing_closing_paren_is_skipped() {
1376 let imports = scan(" * @type {import('./types'.Foo}");
1377 assert!(imports.is_empty());
1378 }
1379
1380 #[test]
1381 fn scan_jsdoc_whitespace_between_paren_and_dot() {
1382 let imports = scan(" * @type {import('./types') .Foo}");
1383 assert_eq!(imports.len(), 1);
1384 assert_eq!(imports[0].source, "./types");
1385 assert_eq!(
1386 imports[0].imported_name,
1387 ImportedName::Named("Foo".to_string())
1388 );
1389 }
1390
1391 #[test]
1392 fn scan_jsdoc_whitespace_between_paren_and_quote() {
1393 let imports = scan(" * @type {import( './types').Foo}");
1394 assert_eq!(imports.len(), 1);
1395 assert_eq!(imports[0].source, "./types");
1396 }
1397
1398 #[test]
1399 fn scan_jsdoc_non_quote_after_paren_skipped() {
1400 let imports = scan(" * @type {import(foo).Bar}");
1401 assert!(imports.is_empty());
1402 }
1403
1404 #[test]
1405 fn scan_jsdoc_ignores_prose_with_import_word() {
1406 let imports = scan(" * This is an important note about imports.");
1407 assert!(imports.is_empty());
1408 }
1409
1410 #[test]
1411 fn scan_jsdoc_utf8_path_works() {
1412 let imports = scan(" * @type {import('./héllo').Foo}");
1413 assert_eq!(imports.len(), 1);
1414 assert_eq!(imports[0].source, "./héllo");
1415 }
1416
1417 #[test]
1418 fn scan_jsdoc_empty_body_is_empty() {
1419 assert!(scan("").is_empty());
1420 }
1421
1422 #[test]
1423 fn scan_jsdoc_no_import_in_body_is_empty() {
1424 assert!(scan(" * @param foo The foo parameter").is_empty());
1425 }
1426
1427 #[test]
1433 fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1434 let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1437 let imports = scan(body);
1438 assert!(
1439 imports.is_empty(),
1440 "prose import() should not be matched; got: {:?}",
1441 imports
1442 .iter()
1443 .map(|i| i.source.as_str())
1444 .collect::<Vec<_>>()
1445 );
1446 }
1447
1448 #[test]
1449 fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1450 let body = "\n * @example\n * const loaders = {\n * admin: () => import('./prose')\n * }";
1451 let imports = scan(body);
1452 assert!(
1453 imports.is_empty(),
1454 "object-literal example import() should not be matched; got: {:?}",
1455 imports
1456 .iter()
1457 .map(|i| i.source.as_str())
1458 .collect::<Vec<_>>()
1459 );
1460 }
1461
1462 #[test]
1463 fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1464 let imports = scan(" * Use {import('./prose')} as an example string.");
1465 assert!(imports.is_empty());
1466 }
1467
1468 #[test]
1469 fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1470 let imports = scan("\n * @example\n * { import('./prose') }\n");
1471 assert!(imports.is_empty());
1472 }
1473
1474 #[test]
1478 fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1479 let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1480 let imports = scan(body);
1481 assert_eq!(imports.len(), 1, "got: {imports:?}");
1482 assert_eq!(imports[0].source, "./real");
1483 assert_eq!(
1484 imports[0].imported_name,
1485 ImportedName::Named("Foo".to_string())
1486 );
1487 }
1488
1489 #[test]
1490 fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1491 let body = "\n * @returns {\n * import('./real').Foo\n * }";
1492 let imports = scan(body);
1493 assert_eq!(imports.len(), 1, "got: {imports:?}");
1494 assert_eq!(imports[0].source, "./real");
1495 assert_eq!(
1496 imports[0].imported_name,
1497 ImportedName::Named("Foo".to_string())
1498 );
1499 }
1500
1501 #[test]
1502 fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1503 let body = "\n * @type\n * { import('./real').Foo }\n";
1504 let imports = scan(body);
1505 assert_eq!(imports.len(), 1, "got: {imports:?}");
1506 assert_eq!(imports[0].source, "./real");
1507 assert_eq!(
1508 imports[0].imported_name,
1509 ImportedName::Named("Foo".to_string())
1510 );
1511 }
1512
1513 #[test]
1514 fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1515 let imports = scan(" * @satisfies {import('./real').Foo}");
1516 assert_eq!(imports.len(), 1, "got: {imports:?}");
1517 assert_eq!(imports[0].source, "./real");
1518 assert_eq!(
1519 imports[0].imported_name,
1520 ImportedName::Named("Foo".to_string())
1521 );
1522 }
1523
1524 #[test]
1525 fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1526 let imports = scan(" * @template {import('./real').Foo} T");
1527 assert_eq!(imports.len(), 1, "got: {imports:?}");
1528 assert_eq!(imports[0].source, "./real");
1529 assert_eq!(
1530 imports[0].imported_name,
1531 ImportedName::Named("Foo".to_string())
1532 );
1533 }
1534
1535 #[test]
1536 fn scan_jsdoc_enum_type_tag_is_still_matched() {
1537 let imports = scan(" * @enum {import('./real').Foo}");
1538 assert_eq!(imports.len(), 1, "got: {imports:?}");
1539 assert_eq!(imports[0].source, "./real");
1540 assert_eq!(
1541 imports[0].imported_name,
1542 ImportedName::Named("Foo".to_string())
1543 );
1544 }
1545
1546 #[test]
1547 fn scan_jsdoc_appends_to_existing_imports() {
1548 let mut imports = vec![ImportInfo {
1549 source: "existing".to_string(),
1550 imported_name: ImportedName::Default,
1551 local_name: "existing".to_string(),
1552 is_type_only: false,
1553 from_style: false,
1554 span: oxc_span::Span::default(),
1555 source_span: oxc_span::Span::default(),
1556 }];
1557 scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1558 assert_eq!(imports.len(), 2);
1559 assert_eq!(imports[0].source, "existing");
1560 assert_eq!(imports[1].source, "./new");
1561 }
1562
1563 #[test]
1564 fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1565 let imports = scan(" * @type {import('./t').Abc}");
1566 assert_eq!(imports.len(), 1);
1567 assert_eq!(
1568 imports[0].imported_name,
1569 ImportedName::Named("Abc".to_string())
1570 );
1571 }
1572
1573 #[test]
1574 fn scan_jsdoc_empty_member_name_is_skipped() {
1575 let imports = scan(" * @type {import('./x').}");
1576 assert!(imports.is_empty());
1577 }
1578}