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