Skip to main content

fallow_extract/
parse.rs

1use std::path::Path;
2
3use oxc_allocator::Allocator;
4use oxc_ast::{
5    AstKind,
6    ast::{Comment, Program},
7};
8use oxc_ast_visit::Visit;
9use oxc_parser::Parser;
10use oxc_span::{SourceType, Span};
11
12use crate::ExportInfo;
13use crate::ModuleInfo;
14use crate::astro::{is_astro_file, parse_astro_to_module};
15use crate::css::{is_css_file, parse_css_to_module};
16use crate::glimmer::{is_glimmer_file, strip_glimmer_templates};
17use crate::graphql::{is_graphql_file, parse_graphql_to_module};
18use crate::html::{is_html_file, parse_html_to_module_with_complexity};
19use crate::mdx::{is_mdx_file, parse_mdx_to_module};
20use crate::sfc::{is_sfc_file, parse_sfc_to_module};
21use crate::visitor::{ModuleInfoExtractor, RouteLoadHarvestMode};
22use fallow_types::discover::FileId;
23use fallow_types::extract::{FlagUse, FunctionComplexity, ImportInfo, ImportedName, VisibilityTag};
24
25struct JsxRetryParse {
26    extractor: ModuleInfoExtractor,
27    semantic_usage: SemanticUsage,
28    complexity: Vec<FunctionComplexity>,
29    flag_uses: Vec<FlagUse>,
30    parsed_suppressions: crate::suppress::ParsedSuppressions,
31}
32
33fn source_type_for_path(path: &Path) -> SourceType {
34    match path.extension().and_then(|ext| ext.to_str()) {
35        Some("gts") => SourceType::ts(),
36        Some("gjs") => SourceType::mjs(),
37        _ => SourceType::from_path(path).unwrap_or_default(),
38    }
39}
40
41/// Parse source text into a [`ModuleInfo`].
42///
43/// When `need_complexity` is false the per-function complexity visitor is
44/// skipped, saving one full AST walk per file.  The dead-code analysis
45/// pipeline never consumes complexity data, so callers that only need
46/// imports/exports should pass `false`.
47pub fn parse_source_to_module(
48    file_id: FileId,
49    path: &Path,
50    source: &str,
51    content_hash: u64,
52    need_complexity: bool,
53) -> ModuleInfo {
54    let mut module =
55        parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
56    module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
57    module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
58    // Keep this post-parse guard as defense in depth. The extractor is also
59    // mode-gated before the AST walk, so incompatible route producer names never
60    // enter the shared cached field in the first place.
61    if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
62        module.load_return_keys = Vec::new();
63        module.has_unharvestable_load = false;
64    }
65    module
66}
67
68/// Whether a file is a SvelteKit page-load producer:
69/// `+page.{ts,server.ts,js,server.js}`. Layout loads (`+layout(.server).{ts,js}`)
70/// are out of scope for v1 (cut A). The leading `+` is a SvelteKit-only
71/// filename convention, so no ordinary module matches.
72fn is_sveltekit_page_load_file(path: &Path) -> bool {
73    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
74        return false;
75    };
76    matches!(
77        name,
78        "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
79    )
80}
81
82fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
83    if is_sveltekit_page_load_file(path) {
84        return RouteLoadHarvestMode::SvelteKitPage;
85    }
86    if is_conventional_route_loader_file(path) {
87        return RouteLoadHarvestMode::ConventionalRoute;
88    }
89    RouteLoadHarvestMode::None
90}
91
92fn is_conventional_route_loader_file(path: &Path) -> bool {
93    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
94        return false;
95    };
96    if name.starts_with('+') {
97        return false;
98    }
99    if !matches!(
100        path.extension().and_then(|ext| ext.to_str()),
101        Some("ts" | "tsx" | "js" | "jsx")
102    ) {
103        return false;
104    }
105    if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
106        && path
107            .parent()
108            .and_then(|parent| parent.file_name())
109            .and_then(|part| part.to_str())
110            .is_some_and(|part| matches!(part, "app" | "src"))
111    {
112        return true;
113    }
114    path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
115}
116
117fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
118    let mut previous = None;
119    for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
120        if previous == Some(app_dir) && part == "routes" {
121            return true;
122        }
123        previous = Some(part);
124    }
125    false
126}
127
128fn parse_source_to_module_inner(
129    file_id: FileId,
130    path: &Path,
131    source: &str,
132    content_hash: u64,
133    need_complexity: bool,
134) -> ModuleInfo {
135    let source = crate::strip_bom(source);
136    if let Some(module) =
137        parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
138    {
139        return module;
140    }
141
142    let stripped_glimmer_source = is_glimmer_file(path)
143        .then(|| strip_glimmer_templates(source))
144        .flatten();
145    let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
146    let source_type = source_type_for_path(path);
147    let allocator = Allocator::default();
148    let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
149
150    let mut parsed_suppressions =
151        crate::suppress::parse_suppressions(&parser_return.program.comments, source);
152
153    let (mut extractor, mut semantic_usage) =
154        build_primary_extractor(&parser_return.program, path, source, source_type);
155
156    let line_offsets = fallow_types::extract::compute_line_offsets(source);
157
158    let (mut complexity, mut flag_uses) = compute_primary_complexity_and_flags(
159        &parser_return.program,
160        parser_source,
161        &extractor.inline_template_findings,
162        &line_offsets,
163        need_complexity,
164    );
165
166    apply_jsx_retry_or_jsdoc(
167        &JsxRetryOrJsdocInput {
168            path,
169            parser_source,
170            source_type,
171            need_complexity,
172            line_offsets: &line_offsets,
173            comments: &parser_return.program.comments,
174            source,
175        },
176        &mut ParseOutputs {
177            extractor: &mut extractor,
178            semantic_usage: &mut semantic_usage,
179            complexity: &mut complexity,
180            flag_uses: &mut flag_uses,
181            parsed_suppressions: &mut parsed_suppressions,
182        },
183    );
184
185    assemble_module_info(ModuleAssemblyInput {
186        extractor,
187        file_id,
188        content_hash,
189        parsed_suppressions,
190        semantic_usage,
191        line_offsets,
192        complexity,
193        flag_uses,
194    })
195}
196
197/// Inputs shared by the JSX retry and the fallback JSDoc enrichment pass.
198struct JsxRetryOrJsdocInput<'a> {
199    path: &'a Path,
200    parser_source: &'a str,
201    source_type: SourceType,
202    need_complexity: bool,
203    line_offsets: &'a [u32],
204    comments: &'a [Comment],
205    source: &'a str,
206}
207
208struct ModuleAssemblyInput {
209    extractor: ModuleInfoExtractor,
210    file_id: FileId,
211    content_hash: u64,
212    parsed_suppressions: crate::suppress::ParsedSuppressions,
213    semantic_usage: SemanticUsage,
214    line_offsets: Vec<u32>,
215    complexity: Vec<FunctionComplexity>,
216    flag_uses: Vec<FlagUse>,
217}
218
219/// Build the primary extractor: run the AST walk (JSX-gated), fold in Glimmer
220/// template usage, and compute import-binding semantic usage.
221fn build_primary_extractor(
222    program: &Program<'_>,
223    path: &Path,
224    source: &str,
225    source_type: SourceType,
226) -> (ModuleInfoExtractor, SemanticUsage) {
227    let mut extractor = ModuleInfoExtractor::new();
228    extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
229    // Gate the React/JSX structural walk on a JSX-capable parse so it is a
230    // no-op on non-JSX files (perf: the `audit` hot path on non-React repos
231    // must not regress).
232    extractor.jsx_capable = source_type.is_jsx();
233    extractor.visit_program(program);
234    extractor.resolve_pending_local_export_specifiers();
235
236    let template_used_imports =
237        collect_glimmer_template_into_extractor(&mut extractor, path, source);
238    let semantic_usage =
239        compute_semantic_usage(program, &extractor.imports, &template_used_imports);
240    extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
241    (extractor, semantic_usage)
242}
243
244/// Compute per-function complexity (with inline-template findings folded in) and
245/// feature-flag uses for the primary parse, honoring `need_complexity`.
246fn compute_primary_complexity_and_flags(
247    program: &Program<'_>,
248    parser_source: &str,
249    inline_template_findings: &[crate::visitor::InlineTemplateFinding],
250    line_offsets: &[u32],
251    need_complexity: bool,
252) -> (Vec<FunctionComplexity>, Vec<FlagUse>) {
253    let mut complexity = if need_complexity {
254        crate::complexity::compute_complexity(program, parser_source, line_offsets)
255    } else {
256        Vec::new()
257    };
258    if need_complexity {
259        append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
260    }
261
262    let flag_uses = crate::flags::extract_flags(
263        program,
264        line_offsets,
265        &[],   // built-in patterns only at parse time
266        &[],   // built-in prefixes only at parse time
267        false, // config object heuristics off at parse time (opt-in via config)
268    );
269    (complexity, flag_uses)
270}
271
272/// Mutable references to the primary-parse outputs a JSX retry replaces wholesale.
273struct ParseOutputs<'a> {
274    extractor: &'a mut ModuleInfoExtractor,
275    semantic_usage: &'a mut SemanticUsage,
276    complexity: &'a mut Vec<FunctionComplexity>,
277    flag_uses: &'a mut Vec<FlagUse>,
278    parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
279}
280
281/// Run the JSX retry parse: when it improves extraction, overwrite every
282/// primary-parse output in place; otherwise apply JSDoc tags to the primary
283/// extractor. The retry's own parse already applies JSDoc tags.
284fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
285    let retry_input = JsxRetryInput {
286        path: input.path,
287        source: input.source,
288        parser_source: input.parser_source,
289        source_type: input.source_type,
290        total_extracted: outputs.extractor.exports.len()
291            + outputs.extractor.imports.len()
292            + outputs.extractor.re_exports.len(),
293        need_complexity: input.need_complexity,
294        line_offsets: input.line_offsets,
295    };
296    let Some(retry) = parse_with_jsx_retry(&retry_input) else {
297        apply_jsdoc_tags_to_extractor(&mut *outputs.extractor, input.comments, input.source);
298        return;
299    };
300    *outputs.extractor = retry.extractor;
301    *outputs.semantic_usage = retry.semantic_usage;
302    *outputs.complexity = retry.complexity;
303    *outputs.flag_uses = retry.flag_uses;
304    *outputs.parsed_suppressions = retry.parsed_suppressions;
305}
306
307/// Apply JSDoc visibility tags and JSDoc `import()` type references to the
308/// extractor's exports/imports for the primary (non-retry) parse.
309fn apply_jsdoc_tags_to_extractor(
310    extractor: &mut ModuleInfoExtractor,
311    comments: &[Comment],
312    source: &str,
313) {
314    apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source);
315    extract_jsdoc_import_types(&mut extractor.imports, comments, source);
316}
317
318/// Convert the finalized extractor into a `ModuleInfo`, attaching semantic-usage,
319/// line-offset, complexity, and flag-use side data.
320fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
321    let ModuleAssemblyInput {
322        extractor,
323        file_id,
324        content_hash,
325        parsed_suppressions,
326        semantic_usage,
327        line_offsets,
328        complexity,
329        flag_uses,
330    } = input;
331    let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
332    info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
333    info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
334    info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
335    info.auto_import_candidates = semantic_usage.auto_import_candidates;
336    info.line_offsets = line_offsets;
337    info.complexity = complexity;
338    info.flag_uses = flag_uses;
339    info
340}
341
342struct JsxRetryInput<'a> {
343    path: &'a Path,
344    source: &'a str,
345    parser_source: &'a str,
346    source_type: SourceType,
347    total_extracted: usize,
348    need_complexity: bool,
349    line_offsets: &'a [u32],
350}
351
352fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
353    if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
354        return None;
355    }
356
357    let jsx_type = if input.source_type.is_typescript() {
358        SourceType::tsx()
359    } else {
360        SourceType::jsx()
361    };
362    let allocator = Allocator::default();
363    let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
364    let mut extractor = ModuleInfoExtractor::new();
365    extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
366    // The retry re-parses a `.js`/`.ts` file that turned out to contain JSX, so
367    // the JSX structural walk applies here too.
368    extractor.jsx_capable = true;
369    extractor.visit_program(&retry_return.program);
370    extractor.resolve_pending_local_export_specifiers();
371    let retry_total =
372        extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
373    if retry_total <= input.total_extracted {
374        return None;
375    }
376
377    let template_used_imports =
378        collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
379    let semantic_usage = compute_semantic_usage(
380        &retry_return.program,
381        &extractor.imports,
382        &template_used_imports,
383    );
384    extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
385    let complexity = retry_complexity(
386        input.need_complexity,
387        &retry_return.program,
388        input.parser_source,
389        input.line_offsets,
390        &extractor,
391    );
392    let flag_uses =
393        crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
394    let parsed_suppressions =
395        crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
396    apply_jsdoc_visibility_tags(
397        &mut extractor.exports,
398        &retry_return.program.comments,
399        input.source,
400    );
401    extract_jsdoc_import_types(
402        &mut extractor.imports,
403        &retry_return.program.comments,
404        input.source,
405    );
406    Some(JsxRetryParse {
407        extractor,
408        semantic_usage,
409        complexity,
410        flag_uses,
411        parsed_suppressions,
412    })
413}
414
415fn retry_complexity(
416    need_complexity: bool,
417    program: &Program<'_>,
418    parser_source: &str,
419    line_offsets: &[u32],
420    extractor: &ModuleInfoExtractor,
421) -> Vec<FunctionComplexity> {
422    if !need_complexity {
423        return Vec::new();
424    }
425    let mut complexity =
426        crate::complexity::compute_complexity(program, parser_source, line_offsets);
427    append_inline_template_complexity(
428        &mut complexity,
429        &extractor.inline_template_findings,
430        line_offsets,
431    );
432    complexity
433}
434
435fn parse_non_js_source_to_module(
436    file_id: FileId,
437    path: &Path,
438    source: &str,
439    content_hash: u64,
440    need_complexity: bool,
441) -> Option<ModuleInfo> {
442    if is_sfc_file(path) {
443        return Some(parse_sfc_to_module(
444            file_id,
445            path,
446            source,
447            content_hash,
448            need_complexity,
449        ));
450    }
451    if is_astro_file(path) {
452        return Some(parse_astro_to_module(
453            file_id,
454            source,
455            content_hash,
456            need_complexity,
457        ));
458    }
459    if is_mdx_file(path) {
460        return Some(parse_mdx_to_module(file_id, source, content_hash));
461    }
462    if is_css_file(path) {
463        return Some(parse_css_to_module(file_id, path, source, content_hash));
464    }
465    if is_graphql_file(path) {
466        return Some(parse_graphql_to_module(file_id, source, content_hash));
467    }
468    if is_html_file(path) {
469        return Some(parse_html_to_module_with_complexity(
470            file_id,
471            source,
472            content_hash,
473            need_complexity,
474        ));
475    }
476    None
477}
478
479/// Scan Glimmer `<template>...</template>` blocks in a `.gts` / `.gjs` file
480/// and fold the result directly into `extractor`. Returns the set of import
481/// local names that the template body credits, so
482/// `compute_import_binding_usage` can skip them when building the unused list.
483///
484/// Mirrors the Angular inline-template path in
485/// `visitor/visit_impl.rs::visit_class`, which pushes
486/// `collect_angular_template_refs(...)` results straight onto
487/// `self.member_accesses`. The Glimmer scan can't run inside the JS visitor
488/// because template bodies are blanked by `strip_glimmer_templates` before
489/// the JS parse. The un-stripped source is only available here in
490/// `parse.rs`, so this is the earliest point we can fold the result in.
491///
492/// `extractor.member_accesses` receives every emitted `MemberAccess`
493/// (including `this.<member>` chain hops that survive even when there are
494/// zero imports; class-member tracking still needs them). Bindings the
495/// template credits are returned, not pushed; the caller threads them into
496/// `compute_import_binding_usage`'s skip-set so the `unused` vector never
497/// names them in the first place. This replaces the previous
498/// `apply_glimmer_template_usage` post-construction `info` mutation and
499/// the `retain` it performed against `unused_import_bindings`.
500fn collect_glimmer_template_into_extractor(
501    extractor: &mut ModuleInfoExtractor,
502    path: &Path,
503    source: &str,
504) -> rustc_hash::FxHashSet<String> {
505    use rustc_hash::FxHashSet;
506
507    if !is_glimmer_file(path) {
508        return FxHashSet::default();
509    }
510    let template_ranges = crate::glimmer::find_template_ranges(source);
511    if template_ranges.is_empty() {
512        return FxHashSet::default();
513    }
514
515    let imported_bindings: FxHashSet<String> = extractor
516        .imports
517        .iter()
518        .filter(|import| !import.local_name.is_empty())
519        .map(|import| import.local_name.clone())
520        .collect();
521
522    let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
523        source,
524        &template_ranges,
525        &imported_bindings,
526    );
527    extractor.member_accesses.extend(usage.member_accesses);
528    usage.used_bindings
529}
530
531/// Synthesise `<template>` complexity findings for inline `@Component({ template: \`...\` })`
532/// decorators captured by the visitor pass.
533///
534/// The template-complexity scanner returns line/col relative to the template
535/// body itself; we replace those with the host file's line/col for the
536/// matched `@Component`/`@Directive` decorator. Anchoring at the decorator
537/// (rather than the literal's opening backtick) gives a useful jump-to-source
538/// landing inside the decorator block and lets `// fallow-ignore-next-line
539/// complexity` comments placed directly above the decorator suppress the
540/// finding through the existing health-side check, with no extra plumbing.
541fn append_inline_template_complexity(
542    complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
543    findings: &[crate::visitor::InlineTemplateFinding],
544    line_offsets: &[u32],
545) {
546    for finding in findings {
547        let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
548            &finding.template_source,
549        ) else {
550            continue;
551        };
552        let (line, col) =
553            fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
554        fc.line = line;
555        fc.col = col;
556        complexity.push(fc);
557    }
558}
559
560/// Apply JSDoc visibility tags (`@public`, `@internal`, `@alpha`, `@beta`) to exports by
561/// matching leading JSDoc comments.
562///
563/// `Comment.attached_to` points to the `export` keyword byte offset, while
564/// `ExportInfo.span` stores the identifier byte offset (e.g., `foo` in
565/// `export const foo`). This function bridges the gap: it collects visibility
566/// comment attachment offsets with their tag, then for each export finds the
567/// nearest preceding attachment point and validates it's part of the same
568/// export statement.
569fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
570    if exports.is_empty() || comments.is_empty() {
571        return;
572    }
573
574    let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
575    if tag_offsets.is_empty() {
576        return;
577    }
578    tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
579
580    for export in exports.iter_mut() {
581        apply_visibility_tag_to_export(export, &tag_offsets, source);
582    }
583}
584
585/// Classify a JSDoc comment body into a visibility tag (and optional reason),
586/// or `None` when no recognized tag is present.
587fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
588    if has_public_tag(text) {
589        Some((VisibilityTag::Public, None))
590    } else if has_internal_tag(text) {
591        Some((VisibilityTag::Internal, None))
592    } else if has_alpha_tag(text) {
593        Some((VisibilityTag::Alpha, None))
594    } else if has_beta_tag(text) {
595        Some((VisibilityTag::Beta, None))
596    } else {
597        let (has_expected_unused, reason) = expected_unused_tag(text);
598        has_expected_unused.then_some((VisibilityTag::ExpectedUnused, reason))
599    }
600}
601
602/// Collect `(attachment_offset, tag, reason)` triples for every JSDoc comment
603/// that carries a recognized visibility tag.
604fn collect_jsdoc_tag_offsets(
605    comments: &[Comment],
606    source: &str,
607) -> Vec<(u32, VisibilityTag, Option<String>)> {
608    let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
609    for comment in comments {
610        if !comment.is_jsdoc() {
611            continue;
612        }
613        let content_span = comment.content_span();
614        let start = content_span.start as usize;
615        let end = (content_span.end as usize).min(source.len());
616        if start >= end {
617            continue;
618        }
619        if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
620            tag_offsets.push((comment.attached_to, tag, reason));
621        }
622    }
623    tag_offsets
624}
625
626/// Apply the best-matching visibility tag to a single export: an exact
627/// attachment-offset hit, else the nearest preceding tag within the same
628/// `export` statement prefix.
629fn apply_visibility_tag_to_export(
630    export: &mut ExportInfo,
631    tag_offsets: &[(u32, VisibilityTag, Option<String>)],
632    source: &str,
633) {
634    if export.span.start == 0 && export.span.end == 0 {
635        return;
636    }
637
638    if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
639        export.visibility = tag_offsets[idx].1;
640        export
641            .expected_unused_reason
642            .clone_from(&tag_offsets[idx].2);
643        return;
644    }
645
646    let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
647    if idx > 0 {
648        let (offset, tag, ref reason) = tag_offsets[idx - 1];
649        let offset = offset as usize;
650        let export_start = export.span.start as usize;
651        if offset < export_start && export_start <= source.len() {
652            let between = &source[offset..export_start];
653            if between.starts_with("export") && !between.contains(';') && !between.contains('}') {
654                export.visibility = tag;
655                export.expected_unused_reason.clone_from(reason);
656            }
657        }
658    }
659}
660
661/// Check if a JSDoc comment body contains an `@internal` tag.
662fn has_internal_tag(comment_text: &str) -> bool {
663    for (i, _) in comment_text.match_indices("@internal") {
664        let after = i + "@internal".len();
665        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
666            return true;
667        }
668    }
669    false
670}
671
672/// Check if a JSDoc comment body contains a `@beta` tag.
673fn has_beta_tag(comment_text: &str) -> bool {
674    for (i, _) in comment_text.match_indices("@beta") {
675        let after = i + "@beta".len();
676        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
677            return true;
678        }
679    }
680    false
681}
682
683/// Check if a JSDoc comment body contains an `@alpha` tag.
684fn has_alpha_tag(comment_text: &str) -> bool {
685    for (i, _) in comment_text.match_indices("@alpha") {
686        let after = i + "@alpha".len();
687        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
688            return true;
689        }
690    }
691    false
692}
693
694fn split_jsdoc_reason(rest: &str) -> Option<String> {
695    for (idx, _) in rest.match_indices("--") {
696        let before_ok = idx == 0
697            || rest[..idx]
698                .chars()
699                .next_back()
700                .is_some_and(char::is_whitespace);
701        let after_idx = idx + 2;
702        let after_ok = after_idx == rest.len()
703            || rest[after_idx..]
704                .chars()
705                .next()
706                .is_some_and(char::is_whitespace);
707        if before_ok && after_ok {
708            let reason = rest[after_idx..].trim();
709            return if reason.is_empty() {
710                None
711            } else {
712                Some(reason.to_string())
713            };
714        }
715    }
716
717    None
718}
719
720/// Return whether an `@expected-unused` tag is present and its optional reason.
721fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
722    for (i, _) in comment_text.match_indices("@expected-unused") {
723        let after = i + "@expected-unused".len();
724        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
725            return (true, split_jsdoc_reason(&comment_text[after..]));
726        }
727    }
728    (false, None)
729}
730
731/// Check if a byte is an identifier-continuation character (alphanumeric or `_`).
732const fn is_ident_char(b: u8) -> bool {
733    b.is_ascii_alphanumeric() || b == b'_'
734}
735
736/// Scan JSDoc comments for `import('./path').Member` type expressions and push
737/// them onto `imports` as type-only imports.
738///
739/// JSDoc supports referencing types from other modules via `import()` expressions
740/// embedded in tag annotations, e.g.:
741///
742/// ```js
743/// /**
744///  * @param foo {import('./types.js').Foo}
745///  * @returns {import('./types').Bar}
746///  */
747/// ```
748///
749/// Without this scanner, the referenced export (`Foo`, `Bar`) is flagged as
750/// unused because no ES `import` statement binds it. The synthesized
751/// `ImportInfo` has `is_type_only: true` and an empty `local_name` so it does
752/// not interfere with `compute_unused_import_bindings` (which skips imports
753/// with empty local names) and does not add a cyclic-dependency edge.
754///
755/// All JSDoc tag contexts (`@param`, `@returns`, `@type`, `@typedef`,
756/// `@callback`, etc.) use the same `{type}` annotation syntax, so scanning
757/// type-bearing brace groups covers every call site without treating prose
758/// examples as imports.
759fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
760    if comments.is_empty() {
761        return;
762    }
763
764    for comment in comments {
765        if !comment.is_jsdoc() {
766            continue;
767        }
768        let content_span = comment.content_span();
769        let start = content_span.start as usize;
770        let end = (content_span.end as usize).min(source.len());
771        if start >= end {
772            continue;
773        }
774        scan_jsdoc_imports_in(&source[start..end], imports);
775    }
776}
777
778/// Parse a single JSDoc comment body for `import('...').Member` expressions.
779///
780/// Matches both single and double quoted path literals and extracts the first
781/// identifier segment after `)\.` as the imported member name. Nested member
782/// access (`import('./x').ns.Foo`) yields `ns` as the imported name, which is
783/// correct for fallow's syntactic analysis since the resolver still adds the
784/// edge to the target module.
785fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
786    let bytes = body.as_bytes();
787    let mut cursor = 0;
788    // Brace-nesting stack (byte offsets of currently-open `{`) maintained
789    // incrementally as the cursor advances, so each `import(` occurrence reuses
790    // the enclosing-brace position instead of rescanning the whole prefix from
791    // offset 0. issue #1843 follow-up: turns the per-occurrence O(prefix) rescan
792    // in the old `enclosing_jsdoc_brace_start` into a single O(body) forward
793    // pass over the comment while staying byte-identical.
794    let mut brace_stack: Vec<usize> = Vec::new();
795    let mut scanned = 0;
796    while let Some(rel) = body[cursor..].find("import(") {
797        let import_pos = cursor + rel;
798        advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
799        if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
800            cursor = import_pos + "import(".len();
801            continue;
802        }
803        let open = import_pos + "import(".len();
804        match locate_jsdoc_import_path(body, bytes, open) {
805            JsdocImportScan::Stop => break,
806            JsdocImportScan::Skip(next) => {
807                cursor = next;
808            }
809            JsdocImportScan::Found { path, after_paren } => {
810                cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
811            }
812        }
813    }
814}
815
816/// Outcome of locating the path literal and closing paren of one JSDoc
817/// `import(...)` occurrence.
818enum JsdocImportScan<'a> {
819    /// Malformed or truncated; abandon the whole scan.
820    Stop,
821    /// Not a recoverable import here; resume scanning from this cursor.
822    Skip(usize),
823    /// A non-empty path was parsed; `after_paren` is the cursor past the `)`.
824    Found { path: &'a str, after_paren: usize },
825}
826
827/// Parse the quoted path literal following `import(` at `open` and locate the
828/// closing paren, returning where the caller should resume.
829fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
830    if open >= bytes.len() {
831        return JsdocImportScan::Stop;
832    }
833    let mut i = open;
834    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
835        i += 1;
836    }
837    if i >= bytes.len() {
838        return JsdocImportScan::Stop;
839    }
840    let quote = bytes[i];
841    if quote != b'\'' && quote != b'"' {
842        return JsdocImportScan::Skip(open);
843    }
844    let path_start = i + 1;
845    let Some(rel_close) = body[path_start..].find(quote as char) else {
846        return JsdocImportScan::Stop;
847    };
848    let path_end = path_start + rel_close;
849    let path = &body[path_start..path_end];
850    if path.is_empty() {
851        return JsdocImportScan::Skip(path_end + 1);
852    }
853    let mut j = path_end + 1;
854    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
855        j += 1;
856    }
857    if j >= bytes.len() || bytes[j] != b')' {
858        return JsdocImportScan::Skip(path_end + 1);
859    }
860    j += 1;
861    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
862        j += 1;
863    }
864    JsdocImportScan::Found {
865        path,
866        after_paren: j,
867    }
868}
869
870/// Resolve the imported name after the `)` (member access -> `Named`, otherwise
871/// `SideEffect`), push the `ImportInfo`, and return the next scan cursor.
872fn resolve_jsdoc_import(
873    body: &str,
874    bytes: &[u8],
875    after_paren: usize,
876    path: &str,
877    imports: &mut Vec<ImportInfo>,
878) -> usize {
879    let mut j = after_paren;
880    if j >= bytes.len() || bytes[j] != b'.' {
881        imports.push(jsdoc_type_import(
882            path,
883            fallow_types::extract::ImportedName::SideEffect,
884        ));
885        return after_paren;
886    }
887    j += 1;
888    let name_start = j;
889    while j < bytes.len() && is_ident_char(bytes[j]) {
890        j += 1;
891    }
892    if name_start == j {
893        // No identifier after `.`: leave the cursor at the post-paren position,
894        // matching the original `continue` (which never updated `cursor` here).
895        return after_paren;
896    }
897    let member = &body[name_start..j];
898    imports.push(jsdoc_type_import(
899        path,
900        fallow_types::extract::ImportedName::Named(member.to_string()),
901    ));
902    j
903}
904
905/// Build a type-only `ImportInfo` for a JSDoc `import('...')` reference. Spans
906/// are defaulted because JSDoc imports carry no real source position.
907fn jsdoc_type_import(
908    source: &str,
909    imported_name: fallow_types::extract::ImportedName,
910) -> ImportInfo {
911    ImportInfo {
912        source: source.to_string(),
913        imported_name,
914        local_name: String::new(),
915        is_type_only: true,
916        from_style: false,
917        span: oxc_span::Span::default(),
918        source_span: oxc_span::Span::default(),
919    }
920}
921
922/// Returns true when byte index `pos` falls inside a JSDoc type-expression
923/// brace group. Prose examples can contain ordinary JavaScript braces, so the
924/// enclosing brace must be tied to a JSDoc type tag. `open_brace` is the
925/// innermost enclosing `{` offset (or `None` when `pos` is at brace depth zero),
926/// supplied by the caller's incrementally-maintained brace stack.
927fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
928    let Some(open_brace) = open_brace else {
929        return false;
930    };
931
932    let prefix = line_prefix_before(body, open_brace);
933    if jsdoc_line_prefix_has_type_tag(prefix) {
934        return true;
935    }
936
937    strip_jsdoc_line_prefix(prefix).is_empty()
938        && preceding_jsdoc_line_has_type_tag(body, open_brace)
939        && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
940}
941
942/// Advance the incrementally-maintained JSDoc brace stack from `*scanned` up to
943/// (but not including) `up_to`, pushing the offset of every `{` and popping on
944/// every `}`. Afterwards `stack.last()` is the innermost enclosing brace of
945/// `up_to`, identical to a fresh scan of `body[..up_to]` but amortized across
946/// every `import(` occurrence in the comment instead of rescanning each prefix
947/// from offset zero (issue #1843 follow-up).
948///
949/// `up_to` must not regress (the caller's `import(` cursor only moves forward);
950/// a non-advancing call is a no-op.
951fn advance_jsdoc_brace_stack(
952    body: &[u8],
953    stack: &mut Vec<usize>,
954    scanned: &mut usize,
955    up_to: usize,
956) {
957    let up_to = up_to.min(body.len());
958    while *scanned < up_to {
959        match body[*scanned] {
960            b'{' => stack.push(*scanned),
961            b'}' => {
962                stack.pop();
963            }
964            _ => {}
965        }
966        *scanned += 1;
967    }
968}
969
970fn line_prefix_before(body: &[u8], pos: usize) -> &str {
971    let start = body[..pos]
972        .iter()
973        .rposition(|&b| b == b'\n')
974        .map_or(0, |idx| idx + 1);
975    std::str::from_utf8(&body[start..pos]).unwrap_or_default()
976}
977
978fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
979    let trimmed = prefix.trim_start();
980    trimmed
981        .strip_prefix('*')
982        .map_or(trimmed, |rest| rest.trim_start())
983}
984
985fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
986    const TYPE_TAGS: [&str; 17] = [
987        "@arg",
988        "@argument",
989        "@augments",
990        "@callback",
991        "@enum",
992        "@extends",
993        "@implements",
994        "@param",
995        "@property",
996        "@prop",
997        "@return",
998        "@returns",
999        "@satisfies",
1000        "@template",
1001        "@this",
1002        "@type",
1003        "@typedef",
1004    ];
1005
1006    let prefix = strip_jsdoc_line_prefix(prefix);
1007    TYPE_TAGS
1008        .iter()
1009        .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
1010}
1011
1012fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
1013    for (idx, _) in text.match_indices(tag) {
1014        let after = idx + tag.len();
1015        if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
1016            return true;
1017        }
1018    }
1019    false
1020}
1021
1022fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1023    let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1024        return false;
1025    };
1026
1027    let line_start = body[..line_end]
1028        .iter()
1029        .rposition(|&b| b == b'\n')
1030        .map_or(0, |idx| idx + 1);
1031
1032    std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1033}
1034
1035fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1036    let mut at_line_start = true;
1037    let mut i = start.min(body.len());
1038    let end = end.min(body.len());
1039    while i < end {
1040        match body[i] {
1041            b'\n' => {
1042                at_line_start = true;
1043                i += 1;
1044            }
1045            b'\r' | b'\t' | b' ' => {
1046                i += 1;
1047            }
1048            b'*' if at_line_start => {
1049                at_line_start = false;
1050                i += 1;
1051            }
1052            _ => return false,
1053        }
1054    }
1055    true
1056}
1057
1058/// Check if a JSDoc comment body contains a `@public` or `@api public` tag.
1059fn has_public_tag(comment_text: &str) -> bool {
1060    for (i, _) in comment_text.match_indices("@public") {
1061        let after = i + "@public".len();
1062        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1063            return true;
1064        }
1065    }
1066    for (i, _) in comment_text.match_indices("@api") {
1067        let after = i + "@api".len();
1068        if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1069            let rest = comment_text[after..].trim_start();
1070            if rest.starts_with("public") {
1071                let after_public = "public".len();
1072                if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1073                    return true;
1074                }
1075            }
1076        }
1077    }
1078    false
1079}
1080
1081#[derive(Debug, Default, PartialEq, Eq)]
1082pub struct ImportBindingUsage {
1083    pub unused: Vec<String>,
1084    pub type_referenced: Vec<String>,
1085    pub value_referenced: Vec<String>,
1086}
1087
1088/// Reference spans proving module-mock API provenance (issue #2068 / #2082).
1089///
1090/// `mock_bindings` holds spans of references that resolve to a mock-API value
1091/// binding: a named `vi` import from `vitest` (any local alias), a named
1092/// `jest` import from `@jest/globals` (any local alias), or the unresolved
1093/// `jest` global that the Jest test environment injects. `vitest_namespaces`
1094/// holds spans of references to a `import * as ns from "vitest"` binding, so
1095/// `ns.vi.mock(...)` can be proven through the namespace identifier.
1096#[derive(Debug, Default, PartialEq, Eq)]
1097pub struct MockApiReferenceSpans {
1098    pub(crate) mock_bindings: rustc_hash::FxHashSet<Span>,
1099    pub(crate) vitest_namespaces: rustc_hash::FxHashSet<Span>,
1100}
1101
1102#[derive(Debug, Default, PartialEq, Eq)]
1103pub struct SemanticUsage {
1104    pub import_binding_usage: ImportBindingUsage,
1105    pub auto_import_candidates: Vec<String>,
1106    pub(crate) mock_api_reference_spans: MockApiReferenceSpans,
1107}
1108
1109pub fn compute_semantic_usage(
1110    program: &Program<'_>,
1111    imports: &[ImportInfo],
1112    template_used: &rustc_hash::FxHashSet<String>,
1113) -> SemanticUsage {
1114    use oxc_semantic::SemanticBuilder;
1115    use rustc_hash::FxHashSet;
1116
1117    let semantic_ret = SemanticBuilder::new().build(program);
1118    let semantic = semantic_ret.semantic;
1119    let scoping = semantic.scoping();
1120    let root_scope = scoping.root_scope_id();
1121
1122    let mut unused = Vec::new();
1123    let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1124    let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1125    for import in imports {
1126        if import.local_name.is_empty() {
1127            continue;
1128        }
1129        let name = oxc_str::Ident::from(import.local_name.as_str());
1130        if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1131            let mut has_references = false;
1132            let mut has_type_references = false;
1133            let mut has_value_references = false;
1134
1135            for reference in scoping.get_resolved_references(symbol_id) {
1136                has_references = true;
1137                has_type_references |= reference.is_type();
1138                has_value_references |= reference.is_value();
1139            }
1140
1141            if !has_references {
1142                if !template_used.contains(&import.local_name) {
1143                    unused.push(import.local_name.clone());
1144                }
1145                continue;
1146            }
1147
1148            if has_type_references {
1149                type_referenced_bindings.insert(import.local_name.clone());
1150            }
1151            if has_value_references {
1152                value_referenced_bindings.insert(import.local_name.clone());
1153            }
1154        }
1155    }
1156
1157    unused.sort_unstable();
1158
1159    let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1160    type_referenced_bindings.sort_unstable();
1161
1162    let mut value_referenced_bindings: Vec<String> =
1163        value_referenced_bindings.into_iter().collect();
1164    value_referenced_bindings.sort_unstable();
1165    let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1166
1167    SemanticUsage {
1168        import_binding_usage: ImportBindingUsage {
1169            unused,
1170            type_referenced: type_referenced_bindings,
1171            value_referenced: value_referenced_bindings,
1172        },
1173        auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1174        mock_api_reference_spans,
1175    }
1176}
1177
1178fn compute_mock_api_reference_spans(
1179    semantic: &oxc_semantic::Semantic<'_>,
1180    imports: &[ImportInfo],
1181    root_scope: oxc_semantic::ScopeId,
1182) -> MockApiReferenceSpans {
1183    let scoping = semantic.scoping();
1184    let mut spans = MockApiReferenceSpans::default();
1185
1186    let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
1187        let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
1188        else {
1189            return;
1190        };
1191        out.extend(
1192            scoping
1193                .get_resolved_references(symbol_id)
1194                .filter_map(|reference| {
1195                    let AstKind::IdentifierReference(identifier) =
1196                        semantic.nodes().kind(reference.node_id())
1197                    else {
1198                        return None;
1199                    };
1200                    Some(identifier.span)
1201                }),
1202        );
1203    };
1204
1205    for import in imports {
1206        if import.is_type_only || import.local_name.is_empty() {
1207            continue;
1208        }
1209        let is_vi_binding = import.source == "vitest"
1210            && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
1211        let is_jest_binding = import.source == "@jest/globals"
1212            && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
1213        let is_vitest_namespace =
1214            import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
1215
1216        if is_vi_binding || is_jest_binding {
1217            collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
1218        } else if is_vitest_namespace {
1219            collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
1220        }
1221    }
1222
1223    // The Jest test environment injects `jest` as a global, so unresolved
1224    // value references named `jest` count as mock-API provenance. Masking only
1225    // ever applies to files the plugin layer classified as test entry points,
1226    // which grounds this in the existing Jest test-root detection. Unresolved
1227    // `vi` stays unproven on purpose (unchanged from #2068): Vitest exposes
1228    // `vi` as a global only under `globals: true`, and without reading that
1229    // config the safe direction is to abstain.
1230    for (name, reference_ids) in scoping.root_unresolved_references() {
1231        if name.as_str() != "jest" {
1232            continue;
1233        }
1234        spans
1235            .mock_bindings
1236            .extend(reference_ids.iter().filter_map(|reference_id| {
1237                let reference = scoping.get_reference(*reference_id);
1238                if !reference.is_value() {
1239                    return None;
1240                }
1241                let AstKind::IdentifierReference(identifier) =
1242                    semantic.nodes().kind(reference.node_id())
1243                else {
1244                    return None;
1245                };
1246                Some(identifier.span)
1247            }));
1248    }
1249
1250    spans
1251}
1252
1253pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
1254    use oxc_semantic::SemanticBuilder;
1255
1256    let semantic_ret = SemanticBuilder::new().build(program);
1257    let semantic = semantic_ret.semantic;
1258    compute_auto_import_candidates_from_semantic(semantic.scoping())
1259}
1260
1261fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1262    use rustc_hash::FxHashSet;
1263
1264    let mut candidates: FxHashSet<String> = FxHashSet::default();
1265    for (name, reference_ids) in scoping.root_unresolved_references() {
1266        if reference_ids
1267            .iter()
1268            .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1269        {
1270            candidates.insert(name.as_str().to_string());
1271        }
1272    }
1273
1274    let mut candidates: Vec<String> = candidates.into_iter().collect();
1275    candidates.sort_unstable();
1276    candidates
1277}
1278
1279/// Use `oxc_semantic` to summarize how import bindings are referenced in the file.
1280///
1281/// An import like `import { foo } from './utils'` where `foo` is never used
1282/// anywhere in the file should not count as a reference to the `foo` export.
1283/// This improves unused-export detection precision.
1284///
1285/// `template_used` lets framework template scanners (Glimmer `<template>`
1286/// blocks today; Vue/Svelte SFCs will follow) credit imports referenced only
1287/// in markup that `oxc_semantic` cannot see. Names in the set are filtered
1288/// out of the `unused` result before it is built. Pass `&FxHashSet::default()`
1289/// when no template scan applies.
1290///
1291/// Note: `get_resolved_references` counts both value-context and type-context
1292/// references. A value import used only as a type annotation (`const x: Foo`)
1293/// will have a type-position reference and will NOT appear in the unused list.
1294/// This is correct: `import { Foo }` (without `type`) may be needed at runtime.
1295pub fn compute_import_binding_usage(
1296    program: &Program<'_>,
1297    imports: &[ImportInfo],
1298    template_used: &rustc_hash::FxHashSet<String>,
1299) -> ImportBindingUsage {
1300    compute_semantic_usage(program, imports, template_used).import_binding_usage
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::{
1306        advance_jsdoc_brace_stack, has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag,
1307        parse_source_to_module, scan_jsdoc_imports_in,
1308    };
1309    use fallow_types::discover::FileId;
1310    use fallow_types::extract::{ImportInfo, ImportedName};
1311    use std::path::Path;
1312
1313    #[test]
1314    fn has_public_tag_matches_bare_tag() {
1315        assert!(has_public_tag(" * @public"));
1316    }
1317
1318    #[test]
1319    fn has_public_tag_matches_api_public_variant() {
1320        assert!(has_public_tag(" * @api public"));
1321    }
1322
1323    #[test]
1324    fn has_public_tag_rejects_partial_word() {
1325        assert!(!has_public_tag(" * @publicly"));
1326    }
1327
1328    #[test]
1329    fn has_public_tag_rejects_at_apipublic() {
1330        assert!(!has_public_tag(" * @apipublic"));
1331    }
1332
1333    #[test]
1334    fn has_public_tag_rejects_missing_at() {
1335        assert!(!has_public_tag(" * public"));
1336    }
1337
1338    #[test]
1339    fn has_internal_tag_matches_bare_tag() {
1340        assert!(has_internal_tag(" * @internal"));
1341    }
1342
1343    #[test]
1344    fn has_internal_tag_rejects_partial_word() {
1345        assert!(!has_internal_tag(" * @internalizer"));
1346    }
1347
1348    #[test]
1349    fn has_internal_tag_rejects_missing_at() {
1350        assert!(!has_internal_tag(" * internal"));
1351    }
1352
1353    #[test]
1354    fn has_beta_tag_matches_bare_tag() {
1355        assert!(has_beta_tag(" * @beta"));
1356    }
1357
1358    #[test]
1359    fn has_beta_tag_rejects_partial_word() {
1360        assert!(!has_beta_tag(" * @betaware"));
1361    }
1362
1363    #[test]
1364    fn has_beta_tag_rejects_missing_at() {
1365        assert!(!has_beta_tag(" * beta"));
1366    }
1367
1368    #[test]
1369    fn alpha_tag_standalone() {
1370        assert!(has_alpha_tag("@alpha"));
1371    }
1372
1373    #[test]
1374    fn alpha_tag_with_text() {
1375        assert!(has_alpha_tag("@alpha Some description"));
1376    }
1377
1378    #[test]
1379    fn alpha_tag_not_prefix() {
1380        assert!(!has_alpha_tag("@alphabet"));
1381    }
1382
1383    #[test]
1384    fn has_alpha_tag_rejects_missing_at() {
1385        assert!(!has_alpha_tag(" * alpha"));
1386    }
1387
1388    fn scan(body: &str) -> Vec<ImportInfo> {
1389        let mut imports = Vec::new();
1390        scan_jsdoc_imports_in(body, &mut imports);
1391        imports
1392    }
1393
1394    #[test]
1395    fn scan_jsdoc_single_import_with_member() {
1396        let imports = scan(" * @param foo {import('./types').Foo}");
1397        assert_eq!(imports.len(), 1);
1398        assert_eq!(imports[0].source, "./types");
1399        assert_eq!(
1400            imports[0].imported_name,
1401            ImportedName::Named("Foo".to_string())
1402        );
1403        assert!(imports[0].is_type_only);
1404        assert!(imports[0].local_name.is_empty());
1405    }
1406
1407    #[test]
1408    fn script_auto_import_candidates_capture_zero_import_value_refs() {
1409        let info = parse_source_to_module(
1410            FileId(0),
1411            Path::new("pages/index.ts"),
1412            r"
1413                useCounter();
1414                const price = formatPrice(10);
1415                const localOnly = () => null;
1416                localOnly();
1417                type Local = UseTypeOnly;
1418            ",
1419            0,
1420            false,
1421        );
1422
1423        assert!(
1424            info.auto_import_candidates
1425                .contains(&"formatPrice".to_string())
1426        );
1427        assert!(
1428            info.auto_import_candidates
1429                .contains(&"useCounter".to_string())
1430        );
1431        assert!(
1432            !info
1433                .auto_import_candidates
1434                .contains(&"UseTypeOnly".to_string())
1435        );
1436        assert!(
1437            !info
1438                .auto_import_candidates
1439                .contains(&"localOnly".to_string())
1440        );
1441    }
1442
1443    #[test]
1444    fn script_auto_import_candidates_skip_explicit_imports() {
1445        let info = parse_source_to_module(
1446            FileId(0),
1447            Path::new("pages/index.ts"),
1448            "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1449            0,
1450            false,
1451        );
1452
1453        assert!(
1454            !info
1455                .auto_import_candidates
1456                .contains(&"useCounter".to_string())
1457        );
1458        assert!(
1459            info.auto_import_candidates
1460                .contains(&"useOther".to_string())
1461        );
1462    }
1463
1464    #[test]
1465    fn scan_jsdoc_double_quoted_path() {
1466        let imports = scan(r#" * @type {import("./types").Foo}"#);
1467        assert_eq!(imports.len(), 1);
1468        assert_eq!(imports[0].source, "./types");
1469    }
1470
1471    #[test]
1472    fn scan_jsdoc_multiple_imports_in_same_body() {
1473        let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1474        assert_eq!(imports.len(), 2);
1475        assert_eq!(imports[0].source, "./a");
1476        assert_eq!(imports[1].source, "./b");
1477    }
1478
1479    #[test]
1480    fn scan_jsdoc_union_annotation_captures_both_members() {
1481        let imports = scan(" * @type {import('./a').A | import('./b').B}");
1482        assert_eq!(imports.len(), 2);
1483        assert_eq!(
1484            imports[0].imported_name,
1485            ImportedName::Named("A".to_string())
1486        );
1487        assert_eq!(
1488            imports[1].imported_name,
1489            ImportedName::Named("B".to_string())
1490        );
1491    }
1492
1493    #[test]
1494    fn scan_jsdoc_nested_member_uses_first_segment() {
1495        let imports = scan(" * @type {import('./types').ns.Foo}");
1496        assert_eq!(imports.len(), 1);
1497        assert_eq!(
1498            imports[0].imported_name,
1499            ImportedName::Named("ns".to_string())
1500        );
1501    }
1502
1503    #[test]
1504    fn scan_jsdoc_parent_relative_path() {
1505        let imports = scan(" * @type {import('../lib/types.js').Foo}");
1506        assert_eq!(imports.len(), 1);
1507        assert_eq!(imports[0].source, "../lib/types.js");
1508    }
1509
1510    #[test]
1511    fn scan_jsdoc_bare_package_specifier() {
1512        let imports = scan(" * @type {import('@scope/pkg').Client}");
1513        assert_eq!(imports.len(), 1);
1514        assert_eq!(imports[0].source, "@scope/pkg");
1515        assert_eq!(
1516            imports[0].imported_name,
1517            ImportedName::Named("Client".to_string())
1518        );
1519    }
1520
1521    #[test]
1522    fn scan_jsdoc_without_member_is_side_effect() {
1523        let imports = scan(" * @type {import('./types')}");
1524        assert_eq!(imports.len(), 1);
1525        assert_eq!(imports[0].source, "./types");
1526        assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1527        assert!(imports[0].is_type_only);
1528    }
1529
1530    #[test]
1531    fn scan_jsdoc_empty_path_is_skipped() {
1532        let imports = scan(" * @type {import('').Foo}");
1533        assert!(imports.is_empty());
1534    }
1535
1536    #[test]
1537    fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1538        let imports = scan(" * @type {import('./truncated");
1539        assert!(imports.is_empty());
1540    }
1541
1542    #[test]
1543    fn scan_jsdoc_missing_closing_paren_is_skipped() {
1544        let imports = scan(" * @type {import('./types'.Foo}");
1545        assert!(imports.is_empty());
1546    }
1547
1548    #[test]
1549    fn scan_jsdoc_whitespace_between_paren_and_dot() {
1550        let imports = scan(" * @type {import('./types') .Foo}");
1551        assert_eq!(imports.len(), 1);
1552        assert_eq!(imports[0].source, "./types");
1553        assert_eq!(
1554            imports[0].imported_name,
1555            ImportedName::Named("Foo".to_string())
1556        );
1557    }
1558
1559    #[test]
1560    fn scan_jsdoc_whitespace_between_paren_and_quote() {
1561        let imports = scan(" * @type {import( './types').Foo}");
1562        assert_eq!(imports.len(), 1);
1563        assert_eq!(imports[0].source, "./types");
1564    }
1565
1566    #[test]
1567    fn scan_jsdoc_non_quote_after_paren_skipped() {
1568        let imports = scan(" * @type {import(foo).Bar}");
1569        assert!(imports.is_empty());
1570    }
1571
1572    #[test]
1573    fn scan_jsdoc_ignores_prose_with_import_word() {
1574        let imports = scan(" * This is an important note about imports.");
1575        assert!(imports.is_empty());
1576    }
1577
1578    #[test]
1579    fn scan_jsdoc_utf8_path_works() {
1580        let imports = scan(" * @type {import('./héllo').Foo}");
1581        assert_eq!(imports.len(), 1);
1582        assert_eq!(imports[0].source, "./héllo");
1583    }
1584
1585    #[test]
1586    fn scan_jsdoc_empty_body_is_empty() {
1587        assert!(scan("").is_empty());
1588    }
1589
1590    #[test]
1591    fn scan_jsdoc_no_import_in_body_is_empty() {
1592        assert!(scan(" * @param foo The foo parameter").is_empty());
1593    }
1594
1595    /// Regression: `import('...')` in JSDoc prose (outside any `{...}` brace
1596    /// group) is documentation/example syntax, not a type annotation. It must
1597    /// not be reported as a real import. Without this scoping check, files
1598    /// whose header doc documents which import forms they handle would surface
1599    /// false-positive unresolved-import findings.
1600    #[test]
1601    fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1602        // Mirrors the exact shape of an extractor's header doc that lists
1603        // import forms as bullet-point examples.
1604        let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1605        let imports = scan(body);
1606        assert!(
1607            imports.is_empty(),
1608            "prose import() should not be matched; got: {:?}",
1609            imports
1610                .iter()
1611                .map(|i| i.source.as_str())
1612                .collect::<Vec<_>>()
1613        );
1614    }
1615
1616    #[test]
1617    fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1618        let body = "\n * @example\n * const loaders = {\n *   admin: () => import('./prose')\n * }";
1619        let imports = scan(body);
1620        assert!(
1621            imports.is_empty(),
1622            "object-literal example import() should not be matched; got: {:?}",
1623            imports
1624                .iter()
1625                .map(|i| i.source.as_str())
1626                .collect::<Vec<_>>()
1627        );
1628    }
1629
1630    #[test]
1631    fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1632        let imports = scan(" * Use {import('./prose')} as an example string.");
1633        assert!(imports.is_empty());
1634    }
1635
1636    #[test]
1637    fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1638        let imports = scan("\n * @example\n * { import('./prose') }\n");
1639        assert!(imports.is_empty());
1640    }
1641
1642    /// A real `{@type ...}` annotation following a prose mention of `import()`
1643    /// must still be matched. The fix narrows scope without breaking the
1644    /// intended JSDoc type-annotation behavior.
1645    #[test]
1646    fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1647        let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1648        let imports = scan(body);
1649        assert_eq!(imports.len(), 1, "got: {imports:?}");
1650        assert_eq!(imports[0].source, "./real");
1651        assert_eq!(
1652            imports[0].imported_name,
1653            ImportedName::Named("Foo".to_string())
1654        );
1655    }
1656
1657    #[test]
1658    fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1659        let body = "\n * @returns {\n *   import('./real').Foo\n * }";
1660        let imports = scan(body);
1661        assert_eq!(imports.len(), 1, "got: {imports:?}");
1662        assert_eq!(imports[0].source, "./real");
1663        assert_eq!(
1664            imports[0].imported_name,
1665            ImportedName::Named("Foo".to_string())
1666        );
1667    }
1668
1669    #[test]
1670    fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1671        let body = "\n * @type\n * { import('./real').Foo }\n";
1672        let imports = scan(body);
1673        assert_eq!(imports.len(), 1, "got: {imports:?}");
1674        assert_eq!(imports[0].source, "./real");
1675        assert_eq!(
1676            imports[0].imported_name,
1677            ImportedName::Named("Foo".to_string())
1678        );
1679    }
1680
1681    #[test]
1682    fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1683        let imports = scan(" * @satisfies {import('./real').Foo}");
1684        assert_eq!(imports.len(), 1, "got: {imports:?}");
1685        assert_eq!(imports[0].source, "./real");
1686        assert_eq!(
1687            imports[0].imported_name,
1688            ImportedName::Named("Foo".to_string())
1689        );
1690    }
1691
1692    #[test]
1693    fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1694        let imports = scan(" * @template {import('./real').Foo} T");
1695        assert_eq!(imports.len(), 1, "got: {imports:?}");
1696        assert_eq!(imports[0].source, "./real");
1697        assert_eq!(
1698            imports[0].imported_name,
1699            ImportedName::Named("Foo".to_string())
1700        );
1701    }
1702
1703    #[test]
1704    fn scan_jsdoc_enum_type_tag_is_still_matched() {
1705        let imports = scan(" * @enum {import('./real').Foo}");
1706        assert_eq!(imports.len(), 1, "got: {imports:?}");
1707        assert_eq!(imports[0].source, "./real");
1708        assert_eq!(
1709            imports[0].imported_name,
1710            ImportedName::Named("Foo".to_string())
1711        );
1712    }
1713
1714    #[test]
1715    fn scan_jsdoc_appends_to_existing_imports() {
1716        let mut imports = vec![ImportInfo {
1717            source: "existing".to_string(),
1718            imported_name: ImportedName::Default,
1719            local_name: "existing".to_string(),
1720            is_type_only: false,
1721            from_style: false,
1722            span: oxc_span::Span::default(),
1723            source_span: oxc_span::Span::default(),
1724        }];
1725        scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1726        assert_eq!(imports.len(), 2);
1727        assert_eq!(imports[0].source, "existing");
1728        assert_eq!(imports[1].source, "./new");
1729    }
1730
1731    #[test]
1732    fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1733        let imports = scan(" * @type {import('./t').Abc}");
1734        assert_eq!(imports.len(), 1);
1735        assert_eq!(
1736            imports[0].imported_name,
1737            ImportedName::Named("Abc".to_string())
1738        );
1739    }
1740
1741    #[test]
1742    fn scan_jsdoc_empty_member_name_is_skipped() {
1743        let imports = scan(" * @type {import('./x').}");
1744        assert!(imports.is_empty());
1745    }
1746
1747    #[test]
1748    fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
1749        // Regression for the issue #1843 follow-up: the enclosing-brace lookup
1750        // is maintained incrementally across the whole comment rather than
1751        // rescanning every prefix. A comment packed with many `import(...)` type
1752        // refs must still extract exactly one import per `{...}` type group, in
1753        // order, with the same paths and member names as before.
1754        use std::fmt::Write as _;
1755        let mut body = String::from("/**\n");
1756        for i in 0..200 {
1757            let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
1758        }
1759        // A prose `import(` outside any type brace group and a nested brace
1760        // must not add spurious imports or shift the enclosing-brace tracking.
1761        body.push_str(" * @remarks import('./ignored') appears in prose here\n");
1762        body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
1763        body.push_str(" */\n");
1764
1765        let imports = scan(&body);
1766        assert_eq!(imports.len(), 201, "got: {imports:?}");
1767        for (i, import) in imports.iter().take(200).enumerate() {
1768            assert_eq!(import.source, format!("./m{i}"));
1769            assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
1770            assert!(import.is_type_only);
1771            assert!(import.local_name.is_empty());
1772        }
1773        // The nested-brace occurrence still resolves against its enclosing group.
1774        assert_eq!(imports[200].source, "./deep");
1775        assert_eq!(
1776            imports[200].imported_name,
1777            ImportedName::Named("D".to_string())
1778        );
1779    }
1780
1781    #[test]
1782    fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
1783        // Cross-checks the incremental brace stack against an independent
1784        // offset-zero rescan over the full prefix, on inputs where the
1785        // `import(` cursor skips over intervening braces (issue #1843 follow-up).
1786        let cases = [
1787            " * @type {import('./a').A} and {plain} then {import('./b').B}",
1788            " * @remarks { import('./skip') } @param x {import('./c').C}",
1789            " * text } stray close { import('./d').D } trailing",
1790            " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
1791        ];
1792        for body in cases {
1793            let bytes = body.as_bytes();
1794            let mut cursor = 0;
1795            while let Some(rel) = body[cursor..].find("import(") {
1796                let import_pos = cursor + rel;
1797                // Independent offset-zero rescan reproducing the old helper.
1798                let mut fresh = Vec::new();
1799                for (idx, &b) in bytes[..import_pos].iter().enumerate() {
1800                    match b {
1801                        b'{' => fresh.push(idx),
1802                        b'}' => {
1803                            fresh.pop();
1804                        }
1805                        _ => {}
1806                    }
1807                }
1808                let mut stack = Vec::new();
1809                let mut scanned = 0;
1810                advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
1811                assert_eq!(
1812                    stack.last().copied(),
1813                    fresh.last().copied(),
1814                    "enclosing brace mismatch at {import_pos} in {body:?}"
1815                );
1816                cursor = import_pos + "import(".len();
1817            }
1818        }
1819    }
1820}