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