Skip to main content

fallow_extract/
parse.rs

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::{ImportInfo, VisibilityTag};
21
22fn source_type_for_path(path: &Path) -> SourceType {
23    match path.extension().and_then(|ext| ext.to_str()) {
24        Some("gts") => SourceType::ts(),
25        Some("gjs") => SourceType::mjs(),
26        _ => SourceType::from_path(path).unwrap_or_default(),
27    }
28}
29
30/// Parse source text into a [`ModuleInfo`].
31///
32/// When `need_complexity` is false the per-function complexity visitor is
33/// skipped, saving one full AST walk per file.  The dead-code analysis
34/// pipeline never consumes complexity data, so callers that only need
35/// imports/exports should pass `false`.
36pub fn parse_source_to_module(
37    file_id: FileId,
38    path: &Path,
39    source: &str,
40    content_hash: u64,
41    need_complexity: bool,
42) -> ModuleInfo {
43    let mut module =
44        parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
45    module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
46    module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
47    module
48}
49
50fn parse_source_to_module_inner(
51    file_id: FileId,
52    path: &Path,
53    source: &str,
54    content_hash: u64,
55    need_complexity: bool,
56) -> ModuleInfo {
57    let source = crate::strip_bom(source);
58    if is_sfc_file(path) {
59        return parse_sfc_to_module(file_id, path, source, content_hash, need_complexity);
60    }
61    if is_astro_file(path) {
62        return parse_astro_to_module(file_id, source, content_hash);
63    }
64    if is_mdx_file(path) {
65        return parse_mdx_to_module(file_id, source, content_hash);
66    }
67    if is_css_file(path) {
68        return parse_css_to_module(file_id, path, source, content_hash);
69    }
70    if is_graphql_file(path) {
71        return parse_graphql_to_module(file_id, source, content_hash);
72    }
73    if is_html_file(path) {
74        return parse_html_to_module_with_complexity(
75            file_id,
76            source,
77            content_hash,
78            need_complexity,
79        );
80    }
81
82    let stripped_glimmer_source = is_glimmer_file(path)
83        .then(|| strip_glimmer_templates(source))
84        .flatten();
85    let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
86    let source_type = source_type_for_path(path);
87    let allocator = Allocator::default();
88    let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
89
90    let mut parsed_suppressions =
91        crate::suppress::parse_suppressions(&parser_return.program.comments, source);
92
93    let mut extractor = ModuleInfoExtractor::new();
94    extractor.visit_program(&parser_return.program);
95    extractor.resolve_pending_local_export_specifiers();
96
97    let mut template_used_imports =
98        collect_glimmer_template_into_extractor(&mut extractor, path, source);
99
100    let mut semantic_usage = compute_semantic_usage(
101        &parser_return.program,
102        &extractor.imports,
103        &template_used_imports,
104    );
105
106    let line_offsets = fallow_types::extract::compute_line_offsets(source);
107
108    let mut complexity = if need_complexity {
109        crate::complexity::compute_complexity(&parser_return.program, parser_source, &line_offsets)
110    } else {
111        Vec::new()
112    };
113    if need_complexity {
114        append_inline_template_complexity(
115            &mut complexity,
116            &extractor.inline_template_findings,
117            &line_offsets,
118        );
119    }
120
121    let mut flag_uses = crate::flags::extract_flags(
122        &parser_return.program,
123        &line_offsets,
124        &[],   // built-in patterns only at parse time
125        &[],   // built-in prefixes only at parse time
126        false, // config object heuristics off at parse time (opt-in via config)
127    );
128
129    let total_extracted =
130        extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
131    let mut used_retry = false;
132    if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
133        let jsx_type = if source_type.is_typescript() {
134            SourceType::tsx()
135        } else {
136            SourceType::jsx()
137        };
138        let allocator2 = Allocator::default();
139        let retry_return = Parser::new(&allocator2, parser_source, jsx_type).parse();
140        let mut retry_extractor = ModuleInfoExtractor::new();
141        retry_extractor.visit_program(&retry_return.program);
142        retry_extractor.resolve_pending_local_export_specifiers();
143        let retry_total = retry_extractor.exports.len()
144            + retry_extractor.imports.len()
145            + retry_extractor.re_exports.len();
146        if retry_total > total_extracted {
147            template_used_imports =
148                collect_glimmer_template_into_extractor(&mut retry_extractor, path, source);
149            semantic_usage = compute_semantic_usage(
150                &retry_return.program,
151                &retry_extractor.imports,
152                &template_used_imports,
153            );
154            if need_complexity {
155                complexity = crate::complexity::compute_complexity(
156                    &retry_return.program,
157                    parser_source,
158                    &line_offsets,
159                );
160                append_inline_template_complexity(
161                    &mut complexity,
162                    &retry_extractor.inline_template_findings,
163                    &line_offsets,
164                );
165            }
166            flag_uses =
167                crate::flags::extract_flags(&retry_return.program, &line_offsets, &[], &[], false);
168            parsed_suppressions =
169                crate::suppress::parse_suppressions(&retry_return.program.comments, source);
170            apply_jsdoc_visibility_tags(
171                &mut retry_extractor.exports,
172                &retry_return.program.comments,
173                source,
174            );
175            extract_jsdoc_import_types(
176                &mut retry_extractor.imports,
177                &retry_return.program.comments,
178                source,
179            );
180            extractor = retry_extractor;
181            used_retry = true;
182        }
183    }
184
185    if !used_retry {
186        apply_jsdoc_visibility_tags(
187            &mut extractor.exports,
188            &parser_return.program.comments,
189            source,
190        );
191        extract_jsdoc_import_types(
192            &mut extractor.imports,
193            &parser_return.program.comments,
194            source,
195        );
196    }
197
198    let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
199    info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
200    info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
201    info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
202    info.auto_import_candidates = semantic_usage.auto_import_candidates;
203    info.line_offsets = line_offsets;
204    info.complexity = complexity;
205    info.flag_uses = flag_uses;
206
207    info
208}
209
210/// Scan Glimmer `<template>...</template>` blocks in a `.gts` / `.gjs` file
211/// and fold the result directly into `extractor`. Returns the set of import
212/// local names that the template body credits, so
213/// `compute_import_binding_usage` can skip them when building the unused list.
214///
215/// Mirrors the Angular inline-template path in
216/// `visitor/visit_impl.rs::visit_class`, which pushes
217/// `collect_angular_template_refs(...)` results straight onto
218/// `self.member_accesses`. The Glimmer scan can't run inside the JS visitor
219/// because template bodies are blanked by `strip_glimmer_templates` before
220/// the JS parse. The un-stripped source is only available here in
221/// `parse.rs`, so this is the earliest point we can fold the result in.
222///
223/// `extractor.member_accesses` receives every emitted `MemberAccess`
224/// (including `this.<member>` chain hops that survive even when there are
225/// zero imports; class-member tracking still needs them). Bindings the
226/// template credits are returned, not pushed; the caller threads them into
227/// `compute_import_binding_usage`'s skip-set so the `unused` vector never
228/// names them in the first place. This replaces the previous
229/// `apply_glimmer_template_usage` post-construction `info` mutation and
230/// the `retain` it performed against `unused_import_bindings`.
231fn collect_glimmer_template_into_extractor(
232    extractor: &mut ModuleInfoExtractor,
233    path: &Path,
234    source: &str,
235) -> rustc_hash::FxHashSet<String> {
236    use rustc_hash::FxHashSet;
237
238    if !is_glimmer_file(path) {
239        return FxHashSet::default();
240    }
241    let template_ranges = crate::glimmer::find_template_ranges(source);
242    if template_ranges.is_empty() {
243        return FxHashSet::default();
244    }
245
246    let imported_bindings: FxHashSet<String> = extractor
247        .imports
248        .iter()
249        .filter(|import| !import.local_name.is_empty())
250        .map(|import| import.local_name.clone())
251        .collect();
252
253    let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
254        source,
255        &template_ranges,
256        &imported_bindings,
257    );
258    extractor.member_accesses.extend(usage.member_accesses);
259    usage.used_bindings
260}
261
262/// Synthesise `<template>` complexity findings for inline `@Component({ template: \`...\` })`
263/// decorators captured by the visitor pass.
264///
265/// The template-complexity scanner returns line/col relative to the template
266/// body itself; we replace those with the host file's line/col for the
267/// matched `@Component`/`@Directive` decorator. Anchoring at the decorator
268/// (rather than the literal's opening backtick) gives a useful jump-to-source
269/// landing inside the decorator block and lets `// fallow-ignore-next-line
270/// complexity` comments placed directly above the decorator suppress the
271/// finding through the existing health-side check, with no extra plumbing.
272fn append_inline_template_complexity(
273    complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
274    findings: &[crate::visitor::InlineTemplateFinding],
275    line_offsets: &[u32],
276) {
277    for finding in findings {
278        let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
279            &finding.template_source,
280        ) else {
281            continue;
282        };
283        let (line, col) =
284            fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
285        fc.line = line;
286        fc.col = col;
287        complexity.push(fc);
288    }
289}
290
291/// Apply JSDoc visibility tags (`@public`, `@internal`, `@alpha`, `@beta`) to exports by
292/// matching leading JSDoc comments.
293///
294/// `Comment.attached_to` points to the `export` keyword byte offset, while
295/// `ExportInfo.span` stores the identifier byte offset (e.g., `foo` in
296/// `export const foo`). This function bridges the gap: it collects visibility
297/// comment attachment offsets with their tag, then for each export finds the
298/// nearest preceding attachment point and validates it's part of the same
299/// export statement.
300fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
301    if exports.is_empty() || comments.is_empty() {
302        return;
303    }
304
305    let mut tag_offsets: Vec<(u32, VisibilityTag)> = Vec::new();
306    for comment in comments {
307        if comment.is_jsdoc() {
308            let content_span = comment.content_span();
309            let start = content_span.start as usize;
310            let end = (content_span.end as usize).min(source.len());
311            if start < end {
312                let text = &source[start..end];
313                let tag = if has_public_tag(text) {
314                    VisibilityTag::Public
315                } else if has_internal_tag(text) {
316                    VisibilityTag::Internal
317                } else if has_alpha_tag(text) {
318                    VisibilityTag::Alpha
319                } else if has_beta_tag(text) {
320                    VisibilityTag::Beta
321                } else if has_expected_unused_tag(text) {
322                    VisibilityTag::ExpectedUnused
323                } else {
324                    continue;
325                };
326                tag_offsets.push((comment.attached_to, tag));
327            }
328        }
329    }
330
331    if tag_offsets.is_empty() {
332        return;
333    }
334
335    tag_offsets.sort_unstable_by_key(|&(offset, _)| offset);
336
337    for export in exports.iter_mut() {
338        if export.span.start == 0 && export.span.end == 0 {
339            continue;
340        }
341
342        if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _)| o) {
343            export.visibility = tag_offsets[idx].1;
344            continue;
345        }
346
347        let idx = tag_offsets.partition_point(|&(o, _)| o <= export.span.start);
348        if idx > 0 {
349            let (offset, tag) = tag_offsets[idx - 1];
350            let offset = offset as usize;
351            let export_start = export.span.start as usize;
352            if offset < export_start && export_start <= source.len() {
353                let between = &source[offset..export_start];
354                if between.starts_with("export") && !between.contains(';') && !between.contains('}')
355                {
356                    export.visibility = tag;
357                }
358            }
359        }
360    }
361}
362
363/// Check if a JSDoc comment body contains an `@internal` tag.
364fn has_internal_tag(comment_text: &str) -> bool {
365    for (i, _) in comment_text.match_indices("@internal") {
366        let after = i + "@internal".len();
367        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
368            return true;
369        }
370    }
371    false
372}
373
374/// Check if a JSDoc comment body contains a `@beta` tag.
375fn has_beta_tag(comment_text: &str) -> bool {
376    for (i, _) in comment_text.match_indices("@beta") {
377        let after = i + "@beta".len();
378        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
379            return true;
380        }
381    }
382    false
383}
384
385/// Check if a JSDoc comment body contains an `@alpha` tag.
386fn has_alpha_tag(comment_text: &str) -> bool {
387    for (i, _) in comment_text.match_indices("@alpha") {
388        let after = i + "@alpha".len();
389        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
390            return true;
391        }
392    }
393    false
394}
395
396/// Check if a JSDoc comment body contains an `@expected-unused` tag.
397fn has_expected_unused_tag(comment_text: &str) -> bool {
398    for (i, _) in comment_text.match_indices("@expected-unused") {
399        let after = i + "@expected-unused".len();
400        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
401            return true;
402        }
403    }
404    false
405}
406
407/// Check if a byte is an identifier-continuation character (alphanumeric or `_`).
408const fn is_ident_char(b: u8) -> bool {
409    b.is_ascii_alphanumeric() || b == b'_'
410}
411
412/// Scan JSDoc comments for `import('./path').Member` type expressions and push
413/// them onto `imports` as type-only imports.
414///
415/// JSDoc supports referencing types from other modules via `import()` expressions
416/// embedded in tag annotations, e.g.:
417///
418/// ```js
419/// /**
420///  * @param foo {import('./types.js').Foo}
421///  * @returns {import('./types').Bar}
422///  */
423/// ```
424///
425/// Without this scanner, the referenced export (`Foo`, `Bar`) is flagged as
426/// unused because no ES `import` statement binds it. The synthesized
427/// `ImportInfo` has `is_type_only: true` and an empty `local_name` so it does
428/// not interfere with `compute_unused_import_bindings` (which skips imports
429/// with empty local names) and does not add a cyclic-dependency edge.
430///
431/// All JSDoc tag contexts (`@param`, `@returns`, `@type`, `@typedef`,
432/// `@callback`, etc.) use the same `{type}` annotation syntax, so scanning
433/// type-bearing brace groups covers every call site without treating prose
434/// examples as imports.
435fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
436    if comments.is_empty() {
437        return;
438    }
439
440    for comment in comments {
441        if !comment.is_jsdoc() {
442            continue;
443        }
444        let content_span = comment.content_span();
445        let start = content_span.start as usize;
446        let end = (content_span.end as usize).min(source.len());
447        if start >= end {
448            continue;
449        }
450        scan_jsdoc_imports_in(&source[start..end], imports);
451    }
452}
453
454/// Parse a single JSDoc comment body for `import('...').Member` expressions.
455///
456/// Matches both single and double quoted path literals and extracts the first
457/// identifier segment after `)\.` as the imported member name. Nested member
458/// access (`import('./x').ns.Foo`) yields `ns` as the imported name, which is
459/// correct for fallow's syntactic analysis since the resolver still adds the
460/// edge to the target module.
461fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
462    let bytes = body.as_bytes();
463    let mut cursor = 0;
464    while let Some(rel) = body[cursor..].find("import(") {
465        let import_pos = cursor + rel;
466        if !is_inside_jsdoc_type_brace_group(bytes, import_pos) {
467            cursor = import_pos + "import(".len();
468            continue;
469        }
470        let open = import_pos + "import(".len();
471        cursor = open;
472        if open >= bytes.len() {
473            break;
474        }
475        let mut i = open;
476        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
477            i += 1;
478        }
479        if i >= bytes.len() {
480            break;
481        }
482        let quote = bytes[i];
483        if quote != b'\'' && quote != b'"' {
484            continue;
485        }
486        let path_start = i + 1;
487        let Some(rel_close) = body[path_start..].find(quote as char) else {
488            break;
489        };
490        let path_end = path_start + rel_close;
491        let path = &body[path_start..path_end];
492        if path.is_empty() {
493            cursor = path_end + 1;
494            continue;
495        }
496        let mut j = path_end + 1;
497        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
498            j += 1;
499        }
500        if j >= bytes.len() || bytes[j] != b')' {
501            cursor = path_end + 1;
502            continue;
503        }
504        j += 1;
505        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
506            j += 1;
507        }
508        cursor = j;
509        if j >= bytes.len() || bytes[j] != b'.' {
510            imports.push(ImportInfo {
511                source: path.to_string(),
512                imported_name: fallow_types::extract::ImportedName::SideEffect,
513                local_name: String::new(),
514                is_type_only: true,
515                from_style: false,
516                span: oxc_span::Span::default(),
517                source_span: oxc_span::Span::default(),
518            });
519            continue;
520        }
521        j += 1;
522        let name_start = j;
523        while j < bytes.len() && is_ident_char(bytes[j]) {
524            j += 1;
525        }
526        if name_start == j {
527            continue;
528        }
529        let member = &body[name_start..j];
530        cursor = j;
531        imports.push(ImportInfo {
532            source: path.to_string(),
533            imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
534            local_name: String::new(),
535            is_type_only: true,
536            from_style: false,
537            span: oxc_span::Span::default(),
538            source_span: oxc_span::Span::default(),
539        });
540    }
541}
542
543/// Returns true when byte index `pos` falls inside a JSDoc type-expression
544/// brace group. Prose examples can contain ordinary JavaScript braces, so the
545/// enclosing brace must be tied to a JSDoc type tag.
546fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize) -> bool {
547    let Some(open_brace) = enclosing_jsdoc_brace_start(body, pos) else {
548        return false;
549    };
550
551    let prefix = line_prefix_before(body, open_brace);
552    if jsdoc_line_prefix_has_type_tag(prefix) {
553        return true;
554    }
555
556    strip_jsdoc_line_prefix(prefix).is_empty()
557        && preceding_jsdoc_line_has_type_tag(body, open_brace)
558        && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
559}
560
561fn enclosing_jsdoc_brace_start(body: &[u8], pos: usize) -> Option<usize> {
562    let mut stack = Vec::new();
563    let limit = pos.min(body.len());
564    for (idx, &b) in body[..limit].iter().enumerate() {
565        match b {
566            b'{' => stack.push(idx),
567            b'}' => {
568                stack.pop();
569            }
570            _ => {}
571        }
572    }
573    stack.pop()
574}
575
576fn line_prefix_before(body: &[u8], pos: usize) -> &str {
577    let start = body[..pos]
578        .iter()
579        .rposition(|&b| b == b'\n')
580        .map_or(0, |idx| idx + 1);
581    std::str::from_utf8(&body[start..pos]).unwrap_or_default()
582}
583
584fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
585    let trimmed = prefix.trim_start();
586    trimmed
587        .strip_prefix('*')
588        .map_or(trimmed, |rest| rest.trim_start())
589}
590
591fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
592    const TYPE_TAGS: [&str; 17] = [
593        "@arg",
594        "@argument",
595        "@augments",
596        "@callback",
597        "@enum",
598        "@extends",
599        "@implements",
600        "@param",
601        "@property",
602        "@prop",
603        "@return",
604        "@returns",
605        "@satisfies",
606        "@template",
607        "@this",
608        "@type",
609        "@typedef",
610    ];
611
612    let prefix = strip_jsdoc_line_prefix(prefix);
613    TYPE_TAGS
614        .iter()
615        .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
616}
617
618fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
619    for (idx, _) in text.match_indices(tag) {
620        let after = idx + tag.len();
621        if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
622            return true;
623        }
624    }
625    false
626}
627
628fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
629    let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
630        return false;
631    };
632
633    let line_start = body[..line_end]
634        .iter()
635        .rposition(|&b| b == b'\n')
636        .map_or(0, |idx| idx + 1);
637
638    std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
639}
640
641fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
642    let mut at_line_start = true;
643    let mut i = start.min(body.len());
644    let end = end.min(body.len());
645    while i < end {
646        match body[i] {
647            b'\n' => {
648                at_line_start = true;
649                i += 1;
650            }
651            b'\r' | b'\t' | b' ' => {
652                i += 1;
653            }
654            b'*' if at_line_start => {
655                at_line_start = false;
656                i += 1;
657            }
658            _ => return false,
659        }
660    }
661    true
662}
663
664/// Check if a JSDoc comment body contains a `@public` or `@api public` tag.
665fn has_public_tag(comment_text: &str) -> bool {
666    for (i, _) in comment_text.match_indices("@public") {
667        let after = i + "@public".len();
668        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
669            return true;
670        }
671    }
672    for (i, _) in comment_text.match_indices("@api") {
673        let after = i + "@api".len();
674        if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
675            let rest = comment_text[after..].trim_start();
676            if rest.starts_with("public") {
677                let after_public = "public".len();
678                if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
679                    return true;
680                }
681            }
682        }
683    }
684    false
685}
686
687#[derive(Debug, Default, PartialEq, Eq)]
688pub struct ImportBindingUsage {
689    pub unused: Vec<String>,
690    pub type_referenced: Vec<String>,
691    pub value_referenced: Vec<String>,
692}
693
694#[derive(Debug, Default, PartialEq, Eq)]
695pub struct SemanticUsage {
696    pub import_binding_usage: ImportBindingUsage,
697    pub auto_import_candidates: Vec<String>,
698}
699
700pub fn compute_semantic_usage(
701    program: &Program<'_>,
702    imports: &[ImportInfo],
703    template_used: &rustc_hash::FxHashSet<String>,
704) -> SemanticUsage {
705    use oxc_semantic::SemanticBuilder;
706    use rustc_hash::FxHashSet;
707
708    let semantic_ret = SemanticBuilder::new().build(program);
709    let semantic = semantic_ret.semantic;
710    let scoping = semantic.scoping();
711    let root_scope = scoping.root_scope_id();
712
713    let mut unused = Vec::new();
714    let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
715    let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
716    for import in imports {
717        if import.local_name.is_empty() {
718            continue;
719        }
720        let name = oxc_str::Ident::from(import.local_name.as_str());
721        if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
722            let mut has_references = false;
723            let mut has_type_references = false;
724            let mut has_value_references = false;
725
726            for reference in scoping.get_resolved_references(symbol_id) {
727                has_references = true;
728                has_type_references |= reference.is_type();
729                has_value_references |= reference.is_value();
730            }
731
732            if !has_references {
733                if !template_used.contains(&import.local_name) {
734                    unused.push(import.local_name.clone());
735                }
736                continue;
737            }
738
739            if has_type_references {
740                type_referenced_bindings.insert(import.local_name.clone());
741            }
742            if has_value_references {
743                value_referenced_bindings.insert(import.local_name.clone());
744            }
745        }
746    }
747
748    unused.sort_unstable();
749
750    let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
751    type_referenced_bindings.sort_unstable();
752
753    let mut value_referenced_bindings: Vec<String> =
754        value_referenced_bindings.into_iter().collect();
755    value_referenced_bindings.sort_unstable();
756
757    SemanticUsage {
758        import_binding_usage: ImportBindingUsage {
759            unused,
760            type_referenced: type_referenced_bindings,
761            value_referenced: value_referenced_bindings,
762        },
763        auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
764    }
765}
766
767pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
768    use oxc_semantic::SemanticBuilder;
769
770    let semantic_ret = SemanticBuilder::new().build(program);
771    let semantic = semantic_ret.semantic;
772    compute_auto_import_candidates_from_semantic(semantic.scoping())
773}
774
775fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
776    use rustc_hash::FxHashSet;
777
778    let mut candidates: FxHashSet<String> = FxHashSet::default();
779    for (name, reference_ids) in scoping.root_unresolved_references() {
780        if reference_ids
781            .iter()
782            .any(|reference_id| scoping.get_reference(*reference_id).is_value())
783        {
784            candidates.insert(name.as_str().to_string());
785        }
786    }
787
788    let mut candidates: Vec<String> = candidates.into_iter().collect();
789    candidates.sort_unstable();
790    candidates
791}
792
793/// Use `oxc_semantic` to summarize how import bindings are referenced in the file.
794///
795/// An import like `import { foo } from './utils'` where `foo` is never used
796/// anywhere in the file should not count as a reference to the `foo` export.
797/// This improves unused-export detection precision.
798///
799/// `template_used` lets framework template scanners (Glimmer `<template>`
800/// blocks today; Vue/Svelte SFCs will follow) credit imports referenced only
801/// in markup that `oxc_semantic` cannot see. Names in the set are filtered
802/// out of the `unused` result before it is built. Pass `&FxHashSet::default()`
803/// when no template scan applies.
804///
805/// Note: `get_resolved_references` counts both value-context and type-context
806/// references. A value import used only as a type annotation (`const x: Foo`)
807/// will have a type-position reference and will NOT appear in the unused list.
808/// This is correct: `import { Foo }` (without `type`) may be needed at runtime.
809pub fn compute_import_binding_usage(
810    program: &Program<'_>,
811    imports: &[ImportInfo],
812    template_used: &rustc_hash::FxHashSet<String>,
813) -> ImportBindingUsage {
814    compute_semantic_usage(program, imports, template_used).import_binding_usage
815}
816
817#[cfg(test)]
818mod tests {
819    use super::{
820        has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
821        scan_jsdoc_imports_in,
822    };
823    use fallow_types::discover::FileId;
824    use fallow_types::extract::{ImportInfo, ImportedName};
825    use std::path::Path;
826
827    #[test]
828    fn has_public_tag_matches_bare_tag() {
829        assert!(has_public_tag(" * @public"));
830    }
831
832    #[test]
833    fn has_public_tag_matches_api_public_variant() {
834        assert!(has_public_tag(" * @api public"));
835    }
836
837    #[test]
838    fn has_public_tag_rejects_partial_word() {
839        assert!(!has_public_tag(" * @publicly"));
840    }
841
842    #[test]
843    fn has_public_tag_rejects_at_apipublic() {
844        assert!(!has_public_tag(" * @apipublic"));
845    }
846
847    #[test]
848    fn has_public_tag_rejects_missing_at() {
849        assert!(!has_public_tag(" * public"));
850    }
851
852    #[test]
853    fn has_internal_tag_matches_bare_tag() {
854        assert!(has_internal_tag(" * @internal"));
855    }
856
857    #[test]
858    fn has_internal_tag_rejects_partial_word() {
859        assert!(!has_internal_tag(" * @internalizer"));
860    }
861
862    #[test]
863    fn has_internal_tag_rejects_missing_at() {
864        assert!(!has_internal_tag(" * internal"));
865    }
866
867    #[test]
868    fn has_beta_tag_matches_bare_tag() {
869        assert!(has_beta_tag(" * @beta"));
870    }
871
872    #[test]
873    fn has_beta_tag_rejects_partial_word() {
874        assert!(!has_beta_tag(" * @betaware"));
875    }
876
877    #[test]
878    fn has_beta_tag_rejects_missing_at() {
879        assert!(!has_beta_tag(" * beta"));
880    }
881
882    #[test]
883    fn alpha_tag_standalone() {
884        assert!(has_alpha_tag("@alpha"));
885    }
886
887    #[test]
888    fn alpha_tag_with_text() {
889        assert!(has_alpha_tag("@alpha Some description"));
890    }
891
892    #[test]
893    fn alpha_tag_not_prefix() {
894        assert!(!has_alpha_tag("@alphabet"));
895    }
896
897    #[test]
898    fn has_alpha_tag_rejects_missing_at() {
899        assert!(!has_alpha_tag(" * alpha"));
900    }
901
902    fn scan(body: &str) -> Vec<ImportInfo> {
903        let mut imports = Vec::new();
904        scan_jsdoc_imports_in(body, &mut imports);
905        imports
906    }
907
908    #[test]
909    fn scan_jsdoc_single_import_with_member() {
910        let imports = scan(" * @param foo {import('./types').Foo}");
911        assert_eq!(imports.len(), 1);
912        assert_eq!(imports[0].source, "./types");
913        assert_eq!(
914            imports[0].imported_name,
915            ImportedName::Named("Foo".to_string())
916        );
917        assert!(imports[0].is_type_only);
918        assert!(imports[0].local_name.is_empty());
919    }
920
921    #[test]
922    fn script_auto_import_candidates_capture_zero_import_value_refs() {
923        let info = parse_source_to_module(
924            FileId(0),
925            Path::new("pages/index.ts"),
926            r"
927                useCounter();
928                const price = formatPrice(10);
929                const localOnly = () => null;
930                localOnly();
931                type Local = UseTypeOnly;
932            ",
933            0,
934            false,
935        );
936
937        assert!(
938            info.auto_import_candidates
939                .contains(&"formatPrice".to_string())
940        );
941        assert!(
942            info.auto_import_candidates
943                .contains(&"useCounter".to_string())
944        );
945        assert!(
946            !info
947                .auto_import_candidates
948                .contains(&"UseTypeOnly".to_string())
949        );
950        assert!(
951            !info
952                .auto_import_candidates
953                .contains(&"localOnly".to_string())
954        );
955    }
956
957    #[test]
958    fn script_auto_import_candidates_skip_explicit_imports() {
959        let info = parse_source_to_module(
960            FileId(0),
961            Path::new("pages/index.ts"),
962            "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
963            0,
964            false,
965        );
966
967        assert!(
968            !info
969                .auto_import_candidates
970                .contains(&"useCounter".to_string())
971        );
972        assert!(
973            info.auto_import_candidates
974                .contains(&"useOther".to_string())
975        );
976    }
977
978    #[test]
979    fn scan_jsdoc_double_quoted_path() {
980        let imports = scan(r#" * @type {import("./types").Foo}"#);
981        assert_eq!(imports.len(), 1);
982        assert_eq!(imports[0].source, "./types");
983    }
984
985    #[test]
986    fn scan_jsdoc_multiple_imports_in_same_body() {
987        let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
988        assert_eq!(imports.len(), 2);
989        assert_eq!(imports[0].source, "./a");
990        assert_eq!(imports[1].source, "./b");
991    }
992
993    #[test]
994    fn scan_jsdoc_union_annotation_captures_both_members() {
995        let imports = scan(" * @type {import('./a').A | import('./b').B}");
996        assert_eq!(imports.len(), 2);
997        assert_eq!(
998            imports[0].imported_name,
999            ImportedName::Named("A".to_string())
1000        );
1001        assert_eq!(
1002            imports[1].imported_name,
1003            ImportedName::Named("B".to_string())
1004        );
1005    }
1006
1007    #[test]
1008    fn scan_jsdoc_nested_member_uses_first_segment() {
1009        let imports = scan(" * @type {import('./types').ns.Foo}");
1010        assert_eq!(imports.len(), 1);
1011        assert_eq!(
1012            imports[0].imported_name,
1013            ImportedName::Named("ns".to_string())
1014        );
1015    }
1016
1017    #[test]
1018    fn scan_jsdoc_parent_relative_path() {
1019        let imports = scan(" * @type {import('../lib/types.js').Foo}");
1020        assert_eq!(imports.len(), 1);
1021        assert_eq!(imports[0].source, "../lib/types.js");
1022    }
1023
1024    #[test]
1025    fn scan_jsdoc_bare_package_specifier() {
1026        let imports = scan(" * @type {import('@scope/pkg').Client}");
1027        assert_eq!(imports.len(), 1);
1028        assert_eq!(imports[0].source, "@scope/pkg");
1029        assert_eq!(
1030            imports[0].imported_name,
1031            ImportedName::Named("Client".to_string())
1032        );
1033    }
1034
1035    #[test]
1036    fn scan_jsdoc_without_member_is_side_effect() {
1037        let imports = scan(" * @type {import('./types')}");
1038        assert_eq!(imports.len(), 1);
1039        assert_eq!(imports[0].source, "./types");
1040        assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1041        assert!(imports[0].is_type_only);
1042    }
1043
1044    #[test]
1045    fn scan_jsdoc_empty_path_is_skipped() {
1046        let imports = scan(" * @type {import('').Foo}");
1047        assert!(imports.is_empty());
1048    }
1049
1050    #[test]
1051    fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1052        let imports = scan(" * @type {import('./truncated");
1053        assert!(imports.is_empty());
1054    }
1055
1056    #[test]
1057    fn scan_jsdoc_missing_closing_paren_is_skipped() {
1058        let imports = scan(" * @type {import('./types'.Foo}");
1059        assert!(imports.is_empty());
1060    }
1061
1062    #[test]
1063    fn scan_jsdoc_whitespace_between_paren_and_dot() {
1064        let imports = scan(" * @type {import('./types') .Foo}");
1065        assert_eq!(imports.len(), 1);
1066        assert_eq!(imports[0].source, "./types");
1067        assert_eq!(
1068            imports[0].imported_name,
1069            ImportedName::Named("Foo".to_string())
1070        );
1071    }
1072
1073    #[test]
1074    fn scan_jsdoc_whitespace_between_paren_and_quote() {
1075        let imports = scan(" * @type {import( './types').Foo}");
1076        assert_eq!(imports.len(), 1);
1077        assert_eq!(imports[0].source, "./types");
1078    }
1079
1080    #[test]
1081    fn scan_jsdoc_non_quote_after_paren_skipped() {
1082        let imports = scan(" * @type {import(foo).Bar}");
1083        assert!(imports.is_empty());
1084    }
1085
1086    #[test]
1087    fn scan_jsdoc_ignores_prose_with_import_word() {
1088        let imports = scan(" * This is an important note about imports.");
1089        assert!(imports.is_empty());
1090    }
1091
1092    #[test]
1093    fn scan_jsdoc_utf8_path_works() {
1094        let imports = scan(" * @type {import('./héllo').Foo}");
1095        assert_eq!(imports.len(), 1);
1096        assert_eq!(imports[0].source, "./héllo");
1097    }
1098
1099    #[test]
1100    fn scan_jsdoc_empty_body_is_empty() {
1101        assert!(scan("").is_empty());
1102    }
1103
1104    #[test]
1105    fn scan_jsdoc_no_import_in_body_is_empty() {
1106        assert!(scan(" * @param foo The foo parameter").is_empty());
1107    }
1108
1109    /// Regression: `import('...')` in JSDoc prose (outside any `{...}` brace
1110    /// group) is documentation/example syntax, not a type annotation. It must
1111    /// not be reported as a real import. Without this scoping check, files
1112    /// whose header doc documents which import forms they handle would surface
1113    /// false-positive unresolved-import findings.
1114    #[test]
1115    fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1116        // Mirrors the exact shape of an extractor's header doc that lists
1117        // import forms as bullet-point examples.
1118        let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1119        let imports = scan(body);
1120        assert!(
1121            imports.is_empty(),
1122            "prose import() should not be matched; got: {:?}",
1123            imports
1124                .iter()
1125                .map(|i| i.source.as_str())
1126                .collect::<Vec<_>>()
1127        );
1128    }
1129
1130    #[test]
1131    fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1132        let body = "\n * @example\n * const loaders = {\n *   admin: () => import('./prose')\n * }";
1133        let imports = scan(body);
1134        assert!(
1135            imports.is_empty(),
1136            "object-literal example import() should not be matched; got: {:?}",
1137            imports
1138                .iter()
1139                .map(|i| i.source.as_str())
1140                .collect::<Vec<_>>()
1141        );
1142    }
1143
1144    #[test]
1145    fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1146        let imports = scan(" * Use {import('./prose')} as an example string.");
1147        assert!(imports.is_empty());
1148    }
1149
1150    #[test]
1151    fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1152        let imports = scan("\n * @example\n * { import('./prose') }\n");
1153        assert!(imports.is_empty());
1154    }
1155
1156    /// A real `{@type ...}` annotation following a prose mention of `import()`
1157    /// must still be matched. The fix narrows scope without breaking the
1158    /// intended JSDoc type-annotation behavior.
1159    #[test]
1160    fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1161        let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1162        let imports = scan(body);
1163        assert_eq!(imports.len(), 1, "got: {imports:?}");
1164        assert_eq!(imports[0].source, "./real");
1165        assert_eq!(
1166            imports[0].imported_name,
1167            ImportedName::Named("Foo".to_string())
1168        );
1169    }
1170
1171    #[test]
1172    fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1173        let body = "\n * @returns {\n *   import('./real').Foo\n * }";
1174        let imports = scan(body);
1175        assert_eq!(imports.len(), 1, "got: {imports:?}");
1176        assert_eq!(imports[0].source, "./real");
1177        assert_eq!(
1178            imports[0].imported_name,
1179            ImportedName::Named("Foo".to_string())
1180        );
1181    }
1182
1183    #[test]
1184    fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1185        let body = "\n * @type\n * { import('./real').Foo }\n";
1186        let imports = scan(body);
1187        assert_eq!(imports.len(), 1, "got: {imports:?}");
1188        assert_eq!(imports[0].source, "./real");
1189        assert_eq!(
1190            imports[0].imported_name,
1191            ImportedName::Named("Foo".to_string())
1192        );
1193    }
1194
1195    #[test]
1196    fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1197        let imports = scan(" * @satisfies {import('./real').Foo}");
1198        assert_eq!(imports.len(), 1, "got: {imports:?}");
1199        assert_eq!(imports[0].source, "./real");
1200        assert_eq!(
1201            imports[0].imported_name,
1202            ImportedName::Named("Foo".to_string())
1203        );
1204    }
1205
1206    #[test]
1207    fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1208        let imports = scan(" * @template {import('./real').Foo} T");
1209        assert_eq!(imports.len(), 1, "got: {imports:?}");
1210        assert_eq!(imports[0].source, "./real");
1211        assert_eq!(
1212            imports[0].imported_name,
1213            ImportedName::Named("Foo".to_string())
1214        );
1215    }
1216
1217    #[test]
1218    fn scan_jsdoc_enum_type_tag_is_still_matched() {
1219        let imports = scan(" * @enum {import('./real').Foo}");
1220        assert_eq!(imports.len(), 1, "got: {imports:?}");
1221        assert_eq!(imports[0].source, "./real");
1222        assert_eq!(
1223            imports[0].imported_name,
1224            ImportedName::Named("Foo".to_string())
1225        );
1226    }
1227
1228    #[test]
1229    fn scan_jsdoc_appends_to_existing_imports() {
1230        let mut imports = vec![ImportInfo {
1231            source: "existing".to_string(),
1232            imported_name: ImportedName::Default,
1233            local_name: "existing".to_string(),
1234            is_type_only: false,
1235            from_style: false,
1236            span: oxc_span::Span::default(),
1237            source_span: oxc_span::Span::default(),
1238        }];
1239        scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1240        assert_eq!(imports.len(), 2);
1241        assert_eq!(imports[0].source, "existing");
1242        assert_eq!(imports[1].source, "./new");
1243    }
1244
1245    #[test]
1246    fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1247        let imports = scan(" * @type {import('./t').Abc}");
1248        assert_eq!(imports.len(), 1);
1249        assert_eq!(
1250            imports[0].imported_name,
1251            ImportedName::Named("Abc".to_string())
1252        );
1253    }
1254
1255    #[test]
1256    fn scan_jsdoc_empty_member_name_is_skipped() {
1257        let imports = scan(" * @type {import('./x').}");
1258        assert!(imports.is_empty());
1259    }
1260}