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_for_extractor(program, &mut extractor, &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    append_declaration_merge_facts(
337        &mut info.semantic_facts,
338        semantic_usage.declaration_merges,
339        0,
340    );
341    info.line_offsets = line_offsets;
342    info.complexity = complexity;
343    info.flag_uses = flag_uses;
344    info
345}
346
347pub fn append_declaration_merge_facts(
348    facts: &mut std::sync::Arc<[fallow_types::extract::SemanticFact]>,
349    mut groups: Vec<fallow_types::extract::DeclarationMergeFact>,
350    byte_offset: u32,
351) {
352    if groups.is_empty() {
353        return;
354    }
355    if byte_offset != 0 {
356        for group in &mut groups {
357            for (start, end) in &mut group.export_spans {
358                *start += byte_offset;
359                *end += byte_offset;
360            }
361        }
362    }
363    let mut merged = std::mem::take(facts).to_vec();
364    merged.extend(
365        groups
366            .into_iter()
367            .map(fallow_types::extract::SemanticFact::DeclarationMerge),
368    );
369    *facts = merged.into();
370}
371
372struct JsxRetryInput<'a> {
373    path: &'a Path,
374    source: &'a str,
375    parser_source: &'a str,
376    source_type: SourceType,
377    total_extracted: usize,
378    need_complexity: bool,
379    line_offsets: &'a [u32],
380}
381
382fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
383    if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
384        return None;
385    }
386
387    let jsx_type = if input.source_type.is_typescript() {
388        SourceType::tsx()
389    } else {
390        SourceType::jsx()
391    };
392    let allocator = Allocator::default();
393    let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
394    let mut extractor = ModuleInfoExtractor::new();
395    extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
396    // The retry re-parses a `.js`/`.ts` file that turned out to contain JSX, so
397    // the JSX structural walk applies here too.
398    extractor.jsx_capable = true;
399    extractor.visit_program(&retry_return.program);
400    extractor.resolve_pending_local_export_specifiers();
401    let retry_total =
402        extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
403    if retry_total <= input.total_extracted {
404        return None;
405    }
406
407    let template_used_imports =
408        collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
409    let semantic_usage = compute_semantic_usage_for_extractor(
410        &retry_return.program,
411        &mut extractor,
412        &template_used_imports,
413    );
414    extractor.resolve_vitest_mock_operations(&semantic_usage.mock_api_reference_spans);
415    let complexity = retry_complexity(
416        input.need_complexity,
417        &retry_return.program,
418        input.parser_source,
419        input.line_offsets,
420        &extractor,
421    );
422    let flag_uses =
423        crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
424    let parsed_suppressions =
425        crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
426    apply_jsdoc_visibility_tags(
427        &mut extractor.exports,
428        &retry_return.program.comments,
429        input.source,
430    );
431    extract_jsdoc_import_types(
432        &mut extractor.imports,
433        &retry_return.program.comments,
434        input.source,
435    );
436    Some(JsxRetryParse {
437        extractor,
438        semantic_usage,
439        complexity,
440        flag_uses,
441        parsed_suppressions,
442    })
443}
444
445fn retry_complexity(
446    need_complexity: bool,
447    program: &Program<'_>,
448    parser_source: &str,
449    line_offsets: &[u32],
450    extractor: &ModuleInfoExtractor,
451) -> Vec<FunctionComplexity> {
452    if !need_complexity {
453        return Vec::new();
454    }
455    let mut complexity =
456        crate::complexity::compute_complexity(program, parser_source, line_offsets);
457    append_inline_template_complexity(
458        &mut complexity,
459        &extractor.inline_template_findings,
460        line_offsets,
461    );
462    complexity
463}
464
465fn parse_non_js_source_to_module(
466    file_id: FileId,
467    path: &Path,
468    source: &str,
469    content_hash: u64,
470    need_complexity: bool,
471) -> Option<ModuleInfo> {
472    if is_sfc_file(path) {
473        return Some(parse_sfc_to_module(
474            file_id,
475            path,
476            source,
477            content_hash,
478            need_complexity,
479        ));
480    }
481    if is_astro_file(path) {
482        return Some(parse_astro_to_module(
483            file_id,
484            source,
485            content_hash,
486            need_complexity,
487        ));
488    }
489    if is_mdx_file(path) {
490        return Some(parse_mdx_to_module(file_id, source, content_hash));
491    }
492    if is_css_file(path) {
493        return Some(parse_css_to_module(file_id, path, source, content_hash));
494    }
495    if is_graphql_file(path) {
496        return Some(parse_graphql_to_module(file_id, source, content_hash));
497    }
498    if is_html_file(path) {
499        return Some(parse_html_to_module_with_complexity(
500            file_id,
501            source,
502            content_hash,
503            need_complexity,
504        ));
505    }
506    None
507}
508
509/// Scan Glimmer `<template>...</template>` blocks in a `.gts` / `.gjs` file
510/// and fold the result directly into `extractor`. Returns the set of import
511/// local names that the template body credits, so
512/// `compute_import_binding_usage` can skip them when building the unused list.
513///
514/// Mirrors the Angular inline-template path in
515/// `visitor/visit_impl.rs::visit_class`, which pushes
516/// `collect_angular_template_refs(...)` results straight onto
517/// `self.member_accesses`. The Glimmer scan can't run inside the JS visitor
518/// because template bodies are blanked by `strip_glimmer_templates` before
519/// the JS parse. The un-stripped source is only available here in
520/// `parse.rs`, so this is the earliest point we can fold the result in.
521///
522/// `extractor.member_accesses` receives every emitted `MemberAccess`
523/// (including `this.<member>` chain hops that survive even when there are
524/// zero imports; class-member tracking still needs them). Bindings the
525/// template credits are returned, not pushed; the caller threads them into
526/// `compute_import_binding_usage`'s skip-set so the `unused` vector never
527/// names them in the first place. This replaces the previous
528/// `apply_glimmer_template_usage` post-construction `info` mutation and
529/// the `retain` it performed against `unused_import_bindings`.
530fn collect_glimmer_template_into_extractor(
531    extractor: &mut ModuleInfoExtractor,
532    path: &Path,
533    source: &str,
534) -> rustc_hash::FxHashSet<String> {
535    use rustc_hash::FxHashSet;
536
537    if !is_glimmer_file(path) {
538        return FxHashSet::default();
539    }
540    let template_ranges = crate::glimmer::find_template_ranges(source);
541    if template_ranges.is_empty() {
542        return FxHashSet::default();
543    }
544
545    let imported_bindings: FxHashSet<String> = extractor
546        .imports
547        .iter()
548        .filter(|import| !import.local_name.is_empty())
549        .map(|import| import.local_name.clone())
550        .collect();
551
552    let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
553        source,
554        &template_ranges,
555        &imported_bindings,
556    );
557    extractor.member_accesses.extend(usage.member_accesses);
558    usage.used_bindings
559}
560
561/// Synthesise `<template>` complexity findings for inline `@Component({ template: \`...\` })`
562/// decorators captured by the visitor pass.
563///
564/// The template-complexity scanner returns line/col relative to the template
565/// body itself; we replace those with the host file's line/col for the
566/// matched `@Component`/`@Directive` decorator. Anchoring at the decorator
567/// (rather than the literal's opening backtick) gives a useful jump-to-source
568/// landing inside the decorator block and lets `// fallow-ignore-next-line
569/// complexity` comments placed directly above the decorator suppress the
570/// finding through the existing health-side check, with no extra plumbing.
571fn append_inline_template_complexity(
572    complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
573    findings: &[crate::visitor::InlineTemplateFinding],
574    line_offsets: &[u32],
575) {
576    for finding in findings {
577        let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
578            &finding.template_source,
579        ) else {
580            continue;
581        };
582        let (line, col) =
583            fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
584        fc.line = line;
585        fc.col = col;
586        complexity.push(fc);
587    }
588}
589
590/// Apply JSDoc visibility tags (`@public`, `@internal`, `@alpha`, `@beta`) to exports by
591/// matching leading JSDoc comments.
592///
593/// `Comment.attached_to` points to the `export` keyword byte offset, while
594/// `ExportInfo.span` stores the identifier byte offset (e.g., `foo` in
595/// `export const foo`). This function bridges the gap: it collects visibility
596/// comment attachment offsets with their tag, then for each export finds the
597/// nearest preceding attachment point and validates it's part of the same
598/// export statement.
599fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
600    if exports.is_empty() || comments.is_empty() {
601        return;
602    }
603
604    let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
605    if tag_offsets.is_empty() {
606        return;
607    }
608    tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
609
610    for export in exports.iter_mut() {
611        apply_visibility_tag_to_export(export, &tag_offsets, source);
612    }
613}
614
615/// Classify a JSDoc comment body into a visibility tag (and optional reason),
616/// or `None` when no recognized tag is present.
617fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
618    if has_public_tag(text) {
619        Some((VisibilityTag::Public, None))
620    } else if has_internal_tag(text) {
621        Some((VisibilityTag::Internal, None))
622    } else if has_alpha_tag(text) {
623        Some((VisibilityTag::Alpha, None))
624    } else if has_beta_tag(text) {
625        Some((VisibilityTag::Beta, None))
626    } else {
627        let (has_expected_unused, reason) = expected_unused_tag(text);
628        has_expected_unused.then_some((VisibilityTag::ExpectedUnused, reason))
629    }
630}
631
632/// Collect `(attachment_offset, tag, reason)` triples for every JSDoc comment
633/// that carries a recognized visibility tag.
634fn collect_jsdoc_tag_offsets(
635    comments: &[Comment],
636    source: &str,
637) -> Vec<(u32, VisibilityTag, Option<String>)> {
638    let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
639    for comment in comments {
640        if !comment.is_jsdoc() {
641            continue;
642        }
643        let content_span = comment.content_span();
644        let start = content_span.start as usize;
645        let end = (content_span.end as usize).min(source.len());
646        if start >= end {
647            continue;
648        }
649        if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
650            tag_offsets.push((comment.attached_to, tag, reason));
651        }
652    }
653    tag_offsets
654}
655
656/// Apply the best-matching visibility tag to a single export: an exact
657/// attachment-offset hit, else the nearest preceding tag within the same
658/// `export` statement prefix.
659fn apply_visibility_tag_to_export(
660    export: &mut ExportInfo,
661    tag_offsets: &[(u32, VisibilityTag, Option<String>)],
662    source: &str,
663) {
664    if export.span.start == 0 && export.span.end == 0 {
665        return;
666    }
667
668    if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
669        export.visibility = tag_offsets[idx].1;
670        export
671            .expected_unused_reason
672            .clone_from(&tag_offsets[idx].2);
673        return;
674    }
675
676    let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
677    if idx > 0 {
678        let (offset, tag, ref reason) = tag_offsets[idx - 1];
679        let offset = offset as usize;
680        let export_start = export.span.start as usize;
681        if offset < export_start && export_start <= source.len() {
682            let between = &source[offset..export_start];
683            if between.starts_with("export") && !between.contains(';') && !between.contains('}') {
684                export.visibility = tag;
685                export.expected_unused_reason.clone_from(reason);
686            }
687        }
688    }
689}
690
691/// Check if a JSDoc comment body contains an `@internal` tag.
692fn has_internal_tag(comment_text: &str) -> bool {
693    for (i, _) in comment_text.match_indices("@internal") {
694        let after = i + "@internal".len();
695        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
696            return true;
697        }
698    }
699    false
700}
701
702/// Check if a JSDoc comment body contains a `@beta` tag.
703fn has_beta_tag(comment_text: &str) -> bool {
704    for (i, _) in comment_text.match_indices("@beta") {
705        let after = i + "@beta".len();
706        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
707            return true;
708        }
709    }
710    false
711}
712
713/// Check if a JSDoc comment body contains an `@alpha` tag.
714fn has_alpha_tag(comment_text: &str) -> bool {
715    for (i, _) in comment_text.match_indices("@alpha") {
716        let after = i + "@alpha".len();
717        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
718            return true;
719        }
720    }
721    false
722}
723
724fn split_jsdoc_reason(rest: &str) -> Option<String> {
725    for (idx, _) in rest.match_indices("--") {
726        let before_ok = idx == 0
727            || rest[..idx]
728                .chars()
729                .next_back()
730                .is_some_and(char::is_whitespace);
731        let after_idx = idx + 2;
732        let after_ok = after_idx == rest.len()
733            || rest[after_idx..]
734                .chars()
735                .next()
736                .is_some_and(char::is_whitespace);
737        if before_ok && after_ok {
738            let reason = rest[after_idx..].trim();
739            return if reason.is_empty() {
740                None
741            } else {
742                Some(reason.to_string())
743            };
744        }
745    }
746
747    None
748}
749
750/// Return whether an `@expected-unused` tag is present and its optional reason.
751fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
752    for (i, _) in comment_text.match_indices("@expected-unused") {
753        let after = i + "@expected-unused".len();
754        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
755            return (true, split_jsdoc_reason(&comment_text[after..]));
756        }
757    }
758    (false, None)
759}
760
761/// Check if a byte is an identifier-continuation character (alphanumeric or `_`).
762const fn is_ident_char(b: u8) -> bool {
763    b.is_ascii_alphanumeric() || b == b'_'
764}
765
766/// Scan JSDoc comments for `import('./path').Member` type expressions and push
767/// them onto `imports` as type-only imports.
768///
769/// JSDoc supports referencing types from other modules via `import()` expressions
770/// embedded in tag annotations, e.g.:
771///
772/// ```js
773/// /**
774///  * @param foo {import('./types.js').Foo}
775///  * @returns {import('./types').Bar}
776///  */
777/// ```
778///
779/// Without this scanner, the referenced export (`Foo`, `Bar`) is flagged as
780/// unused because no ES `import` statement binds it. The synthesized
781/// `ImportInfo` has `is_type_only: true` and an empty `local_name` so it does
782/// not interfere with `compute_unused_import_bindings` (which skips imports
783/// with empty local names) and does not add a cyclic-dependency edge.
784///
785/// All JSDoc tag contexts (`@param`, `@returns`, `@type`, `@typedef`,
786/// `@callback`, etc.) use the same `{type}` annotation syntax, so scanning
787/// type-bearing brace groups covers every call site without treating prose
788/// examples as imports.
789fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
790    if comments.is_empty() {
791        return;
792    }
793
794    for comment in comments {
795        if !comment.is_jsdoc() {
796            continue;
797        }
798        let content_span = comment.content_span();
799        let start = content_span.start as usize;
800        let end = (content_span.end as usize).min(source.len());
801        if start >= end {
802            continue;
803        }
804        scan_jsdoc_imports_in(&source[start..end], imports);
805    }
806}
807
808/// Parse a single JSDoc comment body for `import('...').Member` expressions.
809///
810/// Matches both single and double quoted path literals and extracts the first
811/// identifier segment after `)\.` as the imported member name. Nested member
812/// access (`import('./x').ns.Foo`) yields `ns` as the imported name, which is
813/// correct for fallow's syntactic analysis since the resolver still adds the
814/// edge to the target module.
815fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
816    let bytes = body.as_bytes();
817    let mut cursor = 0;
818    // Brace-nesting stack (byte offsets of currently-open `{`) maintained
819    // incrementally as the cursor advances, so each `import(` occurrence reuses
820    // the enclosing-brace position instead of rescanning the whole prefix from
821    // offset 0. issue #1843 follow-up: turns the per-occurrence O(prefix) rescan
822    // in the old `enclosing_jsdoc_brace_start` into a single O(body) forward
823    // pass over the comment while staying byte-identical.
824    let mut brace_stack: Vec<usize> = Vec::new();
825    let mut scanned = 0;
826    while let Some(rel) = body[cursor..].find("import(") {
827        let import_pos = cursor + rel;
828        advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
829        if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
830            cursor = import_pos + "import(".len();
831            continue;
832        }
833        let open = import_pos + "import(".len();
834        match locate_jsdoc_import_path(body, bytes, open) {
835            JsdocImportScan::Stop => break,
836            JsdocImportScan::Skip(next) => {
837                cursor = next;
838            }
839            JsdocImportScan::Found { path, after_paren } => {
840                cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
841            }
842        }
843    }
844}
845
846/// Outcome of locating the path literal and closing paren of one JSDoc
847/// `import(...)` occurrence.
848enum JsdocImportScan<'a> {
849    /// Malformed or truncated; abandon the whole scan.
850    Stop,
851    /// Not a recoverable import here; resume scanning from this cursor.
852    Skip(usize),
853    /// A non-empty path was parsed; `after_paren` is the cursor past the `)`.
854    Found { path: &'a str, after_paren: usize },
855}
856
857/// Parse the quoted path literal following `import(` at `open` and locate the
858/// closing paren, returning where the caller should resume.
859fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
860    if open >= bytes.len() {
861        return JsdocImportScan::Stop;
862    }
863    let mut i = open;
864    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
865        i += 1;
866    }
867    if i >= bytes.len() {
868        return JsdocImportScan::Stop;
869    }
870    let quote = bytes[i];
871    if quote != b'\'' && quote != b'"' {
872        return JsdocImportScan::Skip(open);
873    }
874    let path_start = i + 1;
875    let Some(rel_close) = body[path_start..].find(quote as char) else {
876        return JsdocImportScan::Stop;
877    };
878    let path_end = path_start + rel_close;
879    let path = &body[path_start..path_end];
880    if path.is_empty() {
881        return JsdocImportScan::Skip(path_end + 1);
882    }
883    let mut j = path_end + 1;
884    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
885        j += 1;
886    }
887    if j >= bytes.len() || bytes[j] != b')' {
888        return JsdocImportScan::Skip(path_end + 1);
889    }
890    j += 1;
891    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
892        j += 1;
893    }
894    JsdocImportScan::Found {
895        path,
896        after_paren: j,
897    }
898}
899
900/// Resolve the imported name after the `)` (member access -> `Named`, otherwise
901/// `SideEffect`), push the `ImportInfo`, and return the next scan cursor.
902fn resolve_jsdoc_import(
903    body: &str,
904    bytes: &[u8],
905    after_paren: usize,
906    path: &str,
907    imports: &mut Vec<ImportInfo>,
908) -> usize {
909    let mut j = after_paren;
910    if j >= bytes.len() || bytes[j] != b'.' {
911        imports.push(jsdoc_type_import(
912            path,
913            fallow_types::extract::ImportedName::SideEffect,
914        ));
915        return after_paren;
916    }
917    j += 1;
918    let name_start = j;
919    while j < bytes.len() && is_ident_char(bytes[j]) {
920        j += 1;
921    }
922    if name_start == j {
923        // No identifier after `.`: leave the cursor at the post-paren position,
924        // matching the original `continue` (which never updated `cursor` here).
925        return after_paren;
926    }
927    let member = &body[name_start..j];
928    imports.push(jsdoc_type_import(
929        path,
930        fallow_types::extract::ImportedName::Named(member.to_string()),
931    ));
932    j
933}
934
935/// Build a type-only `ImportInfo` for a JSDoc `import('...')` reference. Spans
936/// are defaulted because JSDoc imports carry no real source position.
937fn jsdoc_type_import(
938    source: &str,
939    imported_name: fallow_types::extract::ImportedName,
940) -> ImportInfo {
941    ImportInfo {
942        source: source.to_string(),
943        imported_name,
944        local_name: String::new(),
945        is_type_only: true,
946        is_type_only_star: false,
947        from_style: false,
948        span: oxc_span::Span::default(),
949        source_span: oxc_span::Span::default(),
950    }
951}
952
953/// Returns true when byte index `pos` falls inside a JSDoc type-expression
954/// brace group. Prose examples can contain ordinary JavaScript braces, so the
955/// enclosing brace must be tied to a JSDoc type tag. `open_brace` is the
956/// innermost enclosing `{` offset (or `None` when `pos` is at brace depth zero),
957/// supplied by the caller's incrementally-maintained brace stack.
958fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
959    let Some(open_brace) = open_brace else {
960        return false;
961    };
962
963    let prefix = line_prefix_before(body, open_brace);
964    if jsdoc_line_prefix_has_type_tag(prefix) {
965        return true;
966    }
967
968    strip_jsdoc_line_prefix(prefix).is_empty()
969        && preceding_jsdoc_line_has_type_tag(body, open_brace)
970        && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
971}
972
973/// Advance the incrementally-maintained JSDoc brace stack from `*scanned` up to
974/// (but not including) `up_to`, pushing the offset of every `{` and popping on
975/// every `}`. Afterwards `stack.last()` is the innermost enclosing brace of
976/// `up_to`, identical to a fresh scan of `body[..up_to]` but amortized across
977/// every `import(` occurrence in the comment instead of rescanning each prefix
978/// from offset zero (issue #1843 follow-up).
979///
980/// `up_to` must not regress (the caller's `import(` cursor only moves forward);
981/// a non-advancing call is a no-op.
982fn advance_jsdoc_brace_stack(
983    body: &[u8],
984    stack: &mut Vec<usize>,
985    scanned: &mut usize,
986    up_to: usize,
987) {
988    let up_to = up_to.min(body.len());
989    while *scanned < up_to {
990        match body[*scanned] {
991            b'{' => stack.push(*scanned),
992            b'}' => {
993                stack.pop();
994            }
995            _ => {}
996        }
997        *scanned += 1;
998    }
999}
1000
1001fn line_prefix_before(body: &[u8], pos: usize) -> &str {
1002    let start = body[..pos]
1003        .iter()
1004        .rposition(|&b| b == b'\n')
1005        .map_or(0, |idx| idx + 1);
1006    std::str::from_utf8(&body[start..pos]).unwrap_or_default()
1007}
1008
1009fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
1010    let trimmed = prefix.trim_start();
1011    trimmed
1012        .strip_prefix('*')
1013        .map_or(trimmed, |rest| rest.trim_start())
1014}
1015
1016fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
1017    const TYPE_TAGS: [&str; 17] = [
1018        "@arg",
1019        "@argument",
1020        "@augments",
1021        "@callback",
1022        "@enum",
1023        "@extends",
1024        "@implements",
1025        "@param",
1026        "@property",
1027        "@prop",
1028        "@return",
1029        "@returns",
1030        "@satisfies",
1031        "@template",
1032        "@this",
1033        "@type",
1034        "@typedef",
1035    ];
1036
1037    let prefix = strip_jsdoc_line_prefix(prefix);
1038    TYPE_TAGS
1039        .iter()
1040        .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
1041}
1042
1043fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
1044    for (idx, _) in text.match_indices(tag) {
1045        let after = idx + tag.len();
1046        if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
1047            return true;
1048        }
1049    }
1050    false
1051}
1052
1053fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1054    let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1055        return false;
1056    };
1057
1058    let line_start = body[..line_end]
1059        .iter()
1060        .rposition(|&b| b == b'\n')
1061        .map_or(0, |idx| idx + 1);
1062
1063    std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1064}
1065
1066fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1067    let mut at_line_start = true;
1068    let mut i = start.min(body.len());
1069    let end = end.min(body.len());
1070    while i < end {
1071        match body[i] {
1072            b'\n' => {
1073                at_line_start = true;
1074                i += 1;
1075            }
1076            b'\r' | b'\t' | b' ' => {
1077                i += 1;
1078            }
1079            b'*' if at_line_start => {
1080                at_line_start = false;
1081                i += 1;
1082            }
1083            _ => return false,
1084        }
1085    }
1086    true
1087}
1088
1089/// Check if a JSDoc comment body contains a `@public` or `@api public` tag.
1090fn has_public_tag(comment_text: &str) -> bool {
1091    for (i, _) in comment_text.match_indices("@public") {
1092        let after = i + "@public".len();
1093        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1094            return true;
1095        }
1096    }
1097    for (i, _) in comment_text.match_indices("@api") {
1098        let after = i + "@api".len();
1099        if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1100            let rest = comment_text[after..].trim_start();
1101            if rest.starts_with("public") {
1102                let after_public = "public".len();
1103                if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1104                    return true;
1105                }
1106            }
1107        }
1108    }
1109    false
1110}
1111
1112#[derive(Debug, Default, PartialEq, Eq)]
1113pub struct ImportBindingUsage {
1114    pub unused: Vec<String>,
1115    pub type_referenced: Vec<String>,
1116    pub value_referenced: Vec<String>,
1117}
1118
1119/// Reference spans proving module-mock API provenance (issue #2068 / #2082).
1120///
1121/// `mock_bindings` holds spans of references that resolve to a mock-API value
1122/// binding: a named `vi` import from `vitest` (any local alias), a named
1123/// `jest` import from `@jest/globals` (any local alias), or the unresolved
1124/// `jest` global that the Jest test environment injects. `vitest_namespaces`
1125/// holds spans of references to a `import * as ns from "vitest"` binding, so
1126/// `ns.vi.mock(...)` can be proven through the namespace identifier.
1127#[derive(Debug, Default, PartialEq, Eq)]
1128pub struct MockApiReferenceSpans {
1129    pub(crate) mock_bindings: rustc_hash::FxHashSet<Span>,
1130    pub(crate) vitest_namespaces: rustc_hash::FxHashSet<Span>,
1131}
1132
1133#[derive(Debug, Default, PartialEq, Eq)]
1134pub struct SemanticUsage {
1135    pub import_binding_usage: ImportBindingUsage,
1136    pub auto_import_candidates: Vec<String>,
1137    pub declaration_merges: Vec<fallow_types::extract::DeclarationMergeFact>,
1138    pub(crate) mock_api_reference_spans: MockApiReferenceSpans,
1139    pub(crate) module_binding_reference_spans: rustc_hash::FxHashSet<Span>,
1140    /// Non-destructured `require()` bindings nothing in the file references.
1141    /// Moved into `import_binding_usage.unused` by
1142    /// [`compute_semantic_usage_for_extractor`], which is the layer that knows
1143    /// which of them the exported form declares.
1144    pub(crate) unreferenced_import_equals_bindings: Vec<String>,
1145}
1146
1147pub fn compute_semantic_usage_for_extractor(
1148    program: &Program<'_>,
1149    extractor: &mut ModuleInfoExtractor,
1150    template_used: &rustc_hash::FxHashSet<String>,
1151) -> SemanticUsage {
1152    let computed_enum_key_spans = extractor.computed_enum_key_reference_spans();
1153    let require_namespace_bindings = extractor.require_namespace_bindings();
1154    let mut semantic_usage = compute_semantic_usage_with_candidates(
1155        program,
1156        &extractor.imports,
1157        &require_namespace_bindings,
1158        template_used,
1159        &computed_enum_key_spans,
1160    );
1161    extractor.resolve_computed_enum_key_uses(&semantic_usage.module_binding_reference_spans);
1162    report_unreferenced_import_equals_bindings(
1163        &mut semantic_usage,
1164        &extractor.exported_import_equals_names,
1165    );
1166    semantic_usage
1167}
1168
1169/// Move every unreferenced non-destructured `require()` binding into the
1170/// unused import-binding list, except exported import-equals declarations.
1171///
1172/// TypeScript elides an import-equals binding nothing references, exactly as it
1173/// elides an unreferenced `import * as X from './x'`, so such a binding must
1174/// not credit the target's exports; leaving it out deleted every unused-export
1175/// and unused-type row on the target (issue #2365). The edge itself stays, so
1176/// the target is still a reachable file, which is what the namespace-import
1177/// twin does.
1178///
1179/// `export import X = require('./x')` is exempt: the binding is the file's
1180/// public API and has no local reference by construction, so it keeps the
1181/// whole-object credit issue #2373 gives the `import * as X; export { X }`
1182/// twin.
1183fn report_unreferenced_import_equals_bindings(
1184    semantic_usage: &mut SemanticUsage,
1185    exported_import_equals_names: &[String],
1186) {
1187    let unreferenced = std::mem::take(&mut semantic_usage.unreferenced_import_equals_bindings);
1188    if unreferenced.is_empty() {
1189        return;
1190    }
1191    let unused = &mut semantic_usage.import_binding_usage.unused;
1192    unused.extend(unreferenced.into_iter().filter(|name| {
1193        !exported_import_equals_names
1194            .iter()
1195            .any(|exported| exported == name)
1196    }));
1197    // One name, one row: the same binding name reaches this list twice when a
1198    // file declares it both at root and inside a namespace body, and the graph
1199    // reads membership rather than a count.
1200    unused.sort_unstable();
1201    unused.dedup();
1202}
1203
1204fn compute_semantic_usage_with_candidates(
1205    program: &Program<'_>,
1206    imports: &[ImportInfo],
1207    require_namespace_bindings: &[String],
1208    template_used: &rustc_hash::FxHashSet<String>,
1209    module_binding_candidates: &rustc_hash::FxHashSet<Span>,
1210) -> SemanticUsage {
1211    use oxc_semantic::SemanticBuilder;
1212    use rustc_hash::FxHashSet;
1213
1214    let semantic_ret = SemanticBuilder::new().build(program);
1215    let semantic = semantic_ret.semantic;
1216    let scoping = semantic.scoping();
1217    let root_scope = scoping.root_scope_id();
1218
1219    let mut unused = Vec::new();
1220    let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1221    let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1222    for import in imports {
1223        if import.local_name.is_empty() {
1224            continue;
1225        }
1226        if let Some((has_references, has_type_references, has_value_references)) =
1227            binding_reference_usage(scoping, &import.local_name)
1228        {
1229            if !has_references {
1230                if !template_used.contains(&import.local_name) {
1231                    unused.push(import.local_name.clone());
1232                }
1233                continue;
1234            }
1235
1236            if has_type_references {
1237                type_referenced_bindings.insert(import.local_name.clone());
1238            }
1239            if has_value_references {
1240                value_referenced_bindings.insert(import.local_name.clone());
1241            }
1242        }
1243    }
1244
1245    let import_equals = classify_import_equals_bindings(
1246        scoping,
1247        require_namespace_bindings,
1248        template_used,
1249        &mut type_referenced_bindings,
1250        &mut value_referenced_bindings,
1251    );
1252
1253    unused.sort_unstable();
1254
1255    let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1256    type_referenced_bindings.sort_unstable();
1257
1258    let mut value_referenced_bindings: Vec<String> =
1259        value_referenced_bindings.into_iter().collect();
1260    value_referenced_bindings.sort_unstable();
1261    let mock_api_reference_spans = compute_mock_api_reference_spans(&semantic, imports, root_scope);
1262    let declaration_merges = declaration_merge_facts(&semantic);
1263    let mut module_binding_reference_spans = FxHashSet::default();
1264    if !module_binding_candidates.is_empty() {
1265        for symbol_id in scoping.symbol_ids() {
1266            if scoping.symbol_scope_id(symbol_id) != root_scope {
1267                continue;
1268            }
1269            module_binding_reference_spans.extend(
1270                scoping
1271                    .get_resolved_references(symbol_id)
1272                    .filter_map(|reference| {
1273                        let AstKind::IdentifierReference(identifier) =
1274                            semantic.nodes().kind(reference.node_id())
1275                        else {
1276                            return None;
1277                        };
1278                        module_binding_candidates
1279                            .contains(&identifier.span)
1280                            .then_some(identifier.span)
1281                    }),
1282            );
1283        }
1284    }
1285
1286    SemanticUsage {
1287        import_binding_usage: ImportBindingUsage {
1288            unused,
1289            type_referenced: type_referenced_bindings,
1290            value_referenced: value_referenced_bindings,
1291        },
1292        auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1293        declaration_merges,
1294        mock_api_reference_spans,
1295        module_binding_reference_spans,
1296        unreferenced_import_equals_bindings: import_equals.unreferenced,
1297    }
1298}
1299
1300/// Verdicts [`classify_import_equals_bindings`] reaches per binding name.
1301#[derive(Default)]
1302struct ImportEqualsClassification {
1303    /// Names with no resolved reference anywhere in the file.
1304    unreferenced: Vec<String>,
1305}
1306
1307/// Aggregate references for every binding with `local_name`, including
1308/// namespace and ambient-module scopes. Module graph binding lists are
1309/// name-keyed, so duplicate spellings fail closed: any live binding keeps the
1310/// shared edge classified instead of declaring it unused.
1311fn binding_reference_usage(
1312    scoping: &oxc_semantic::Scoping,
1313    local_name: &str,
1314) -> Option<(bool, bool, bool)> {
1315    let mut found_binding = false;
1316    let mut has_references = false;
1317    let mut has_type_references = false;
1318    let mut has_value_references = false;
1319    for symbol_id in scoping
1320        .symbol_ids()
1321        .filter(|symbol_id| scoping.symbol_name(*symbol_id) == local_name)
1322    {
1323        found_binding = true;
1324        for reference in scoping.get_resolved_references(symbol_id) {
1325            has_references = true;
1326            has_type_references |= reference.is_type();
1327            has_value_references |= reference.is_value();
1328        }
1329    }
1330    if found_binding
1331        && let Some(reference_ids) = scoping.root_unresolved_references().get(local_name)
1332    {
1333        for reference_id in reference_ids {
1334            let reference = scoping.get_reference(*reference_id);
1335            has_references = true;
1336            has_type_references |= reference.is_type();
1337            has_value_references |= reference.is_value();
1338        }
1339    }
1340    found_binding.then_some((has_references, has_type_references, has_value_references))
1341}
1342
1343/// Classify non-destructured `require()` bindings for type and value usage and
1344/// report which names nothing in the file references.
1345///
1346/// The binding lives in both the type and the value namespace, the same way
1347/// `import * as X from './y'` does, but the require path records it outside
1348/// `imports`, so the caller's `imports` loop never sees it. Without a
1349/// type-space entry, `X.SomeType` in an annotation leaves the target's type
1350/// exports uncredited (issue #2365).
1351///
1352/// A name with no resolved reference is returned as unreferenced, the same
1353/// verdict the `imports` loop reaches for an unreferenced `import * as X`: the
1354/// declaration is erased by TypeScript, so it must not buy the target a
1355/// whole-object credit. A name used only by a framework template is referenced,
1356/// matching the `template_used` skip the `imports` loop applies.
1357///
1358fn classify_import_equals_bindings(
1359    scoping: &oxc_semantic::Scoping,
1360    import_equals_bindings: &[String],
1361    template_used: &rustc_hash::FxHashSet<String>,
1362    type_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1363    value_referenced_bindings: &mut rustc_hash::FxHashSet<String>,
1364) -> ImportEqualsClassification {
1365    if import_equals_bindings.is_empty() {
1366        return ImportEqualsClassification::default();
1367    }
1368
1369    let mut classification = ImportEqualsClassification::default();
1370    for local_name in import_equals_bindings {
1371        if local_name.is_empty() {
1372            continue;
1373        }
1374        let Some((has_references, has_type_references, has_value_references)) =
1375            binding_reference_usage(scoping, local_name)
1376        else {
1377            continue;
1378        };
1379        if !has_references {
1380            if !template_used.contains(local_name) {
1381                classification.unreferenced.push(local_name.clone());
1382            }
1383            continue;
1384        }
1385        if has_type_references {
1386            type_referenced_bindings.insert(local_name.clone());
1387        }
1388        if has_value_references {
1389            value_referenced_bindings.insert(local_name.clone());
1390        }
1391    }
1392    classification
1393}
1394
1395#[derive(Clone, Copy, PartialEq, Eq)]
1396enum MergeDeclarationKind {
1397    Interface,
1398    Class,
1399    Function,
1400    Enum,
1401    Namespace,
1402}
1403
1404fn declaration_merge_facts(
1405    semantic: &oxc_semantic::Semantic<'_>,
1406) -> Vec<fallow_types::extract::DeclarationMergeFact> {
1407    use fallow_types::extract::DeclarationMergeFact;
1408
1409    let scoping = semantic.scoping();
1410    let mut groups = Vec::new();
1411    for symbol_id in scoping.symbol_ids() {
1412        let declarations: Vec<_> = scoping
1413            .symbol_declarations(symbol_id)
1414            .filter_map(|node_id| merge_declaration(semantic.nodes().kind(node_id)))
1415            .collect();
1416        if declarations.len() < 2 {
1417            continue;
1418        }
1419        let mut selected = Vec::new();
1420        for (index, (kind, span)) in declarations.iter().enumerate() {
1421            // Self-compatible kinds (interface, enum, namespace) would otherwise
1422            // always select themselves, grouping declarations that cannot merge
1423            // with each other (`interface Foo` plus `enum Foo`).
1424            if declarations
1425                .iter()
1426                .enumerate()
1427                .any(|(other_index, (other, _))| {
1428                    other_index != index && compatible_merge(*kind, *other)
1429                })
1430            {
1431                selected.push((span.start, span.end));
1432            }
1433        }
1434        selected.sort_unstable();
1435        selected.dedup();
1436        if selected.len() > 1 {
1437            groups.push(DeclarationMergeFact {
1438                export_spans: selected,
1439            });
1440        }
1441    }
1442    groups.sort_unstable_by_key(|group| group.export_spans[0]);
1443    groups
1444}
1445
1446fn merge_declaration(kind: AstKind<'_>) -> Option<(MergeDeclarationKind, Span)> {
1447    match kind {
1448        AstKind::TSInterfaceDeclaration(declaration) => {
1449            Some((MergeDeclarationKind::Interface, declaration.id.span))
1450        }
1451        AstKind::Class(declaration) => declaration
1452            .id
1453            .as_ref()
1454            .map(|id| (MergeDeclarationKind::Class, id.span)),
1455        AstKind::Function(declaration) => declaration
1456            .id
1457            .as_ref()
1458            .map(|id| (MergeDeclarationKind::Function, id.span)),
1459        AstKind::TSEnumDeclaration(declaration) if !declaration.r#const => {
1460            Some((MergeDeclarationKind::Enum, declaration.id.span))
1461        }
1462        AstKind::TSModuleDeclaration(declaration) => match &declaration.id {
1463            oxc_ast::ast::TSModuleDeclarationName::Identifier(id) => {
1464                Some((MergeDeclarationKind::Namespace, id.span))
1465            }
1466            oxc_ast::ast::TSModuleDeclarationName::StringLiteral(_) => None,
1467        },
1468        _ => None,
1469    }
1470}
1471
1472const fn compatible_merge(left: MergeDeclarationKind, right: MergeDeclarationKind) -> bool {
1473    use MergeDeclarationKind::{Class, Enum, Function, Interface, Namespace};
1474
1475    matches!(
1476        (left, right),
1477        (Interface, Interface | Class | Namespace)
1478            | (Class, Interface | Namespace)
1479            | (Function, Namespace)
1480            | (Enum, Enum | Namespace)
1481            | (Namespace, Interface | Class | Function | Enum | Namespace)
1482    )
1483}
1484
1485fn compute_mock_api_reference_spans(
1486    semantic: &oxc_semantic::Semantic<'_>,
1487    imports: &[ImportInfo],
1488    root_scope: oxc_semantic::ScopeId,
1489) -> MockApiReferenceSpans {
1490    let scoping = semantic.scoping();
1491    let mut spans = MockApiReferenceSpans::default();
1492
1493    let collect_binding_spans = |local_name: &str, out: &mut rustc_hash::FxHashSet<Span>| {
1494        let Some(symbol_id) = scoping.get_binding(root_scope, oxc_str::Ident::from(local_name))
1495        else {
1496            return;
1497        };
1498        out.extend(
1499            scoping
1500                .get_resolved_references(symbol_id)
1501                .filter_map(|reference| {
1502                    let AstKind::IdentifierReference(identifier) =
1503                        semantic.nodes().kind(reference.node_id())
1504                    else {
1505                        return None;
1506                    };
1507                    Some(identifier.span)
1508                }),
1509        );
1510    };
1511
1512    for import in imports {
1513        if import.is_type_only || import.local_name.is_empty() {
1514            continue;
1515        }
1516        let is_vi_binding = import.source == "vitest"
1517            && matches!(&import.imported_name, ImportedName::Named(name) if name == "vi");
1518        let is_jest_binding = import.source == "@jest/globals"
1519            && matches!(&import.imported_name, ImportedName::Named(name) if name == "jest");
1520        let is_vitest_namespace =
1521            import.source == "vitest" && matches!(&import.imported_name, ImportedName::Namespace);
1522
1523        if is_vi_binding || is_jest_binding {
1524            collect_binding_spans(&import.local_name, &mut spans.mock_bindings);
1525        } else if is_vitest_namespace {
1526            collect_binding_spans(&import.local_name, &mut spans.vitest_namespaces);
1527        }
1528    }
1529
1530    // The Jest test environment injects `jest` as a global, so unresolved
1531    // value references named `jest` count as mock-API provenance. Masking only
1532    // ever applies to files the plugin layer classified as test entry points,
1533    // which grounds this in the existing Jest test-root detection. Unresolved
1534    // `vi` stays unproven on purpose (unchanged from #2068): Vitest exposes
1535    // `vi` as a global only under `globals: true`, and without reading that
1536    // config the safe direction is to abstain.
1537    for (name, reference_ids) in scoping.root_unresolved_references() {
1538        if name.as_str() != "jest" {
1539            continue;
1540        }
1541        spans
1542            .mock_bindings
1543            .extend(reference_ids.iter().filter_map(|reference_id| {
1544                let reference = scoping.get_reference(*reference_id);
1545                if !reference.is_value() {
1546                    return None;
1547                }
1548                let AstKind::IdentifierReference(identifier) =
1549                    semantic.nodes().kind(reference.node_id())
1550                else {
1551                    return None;
1552                };
1553                Some(identifier.span)
1554            }));
1555    }
1556
1557    spans
1558}
1559
1560fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1561    use rustc_hash::FxHashSet;
1562
1563    let mut candidates: FxHashSet<String> = FxHashSet::default();
1564    for (name, reference_ids) in scoping.root_unresolved_references() {
1565        if reference_ids
1566            .iter()
1567            .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1568        {
1569            candidates.insert(name.as_str().to_string());
1570        }
1571    }
1572
1573    let mut candidates: Vec<String> = candidates.into_iter().collect();
1574    candidates.sort_unstable();
1575    candidates
1576}
1577
1578/// Use `oxc_semantic` to summarize how import bindings are referenced in the file.
1579///
1580/// An import like `import { foo } from './utils'` where `foo` is never used
1581/// anywhere in the file should not count as a reference to the `foo` export.
1582/// This improves unused-export detection precision.
1583///
1584/// `template_used` lets framework template scanners (Glimmer `<template>`
1585/// blocks today; Vue/Svelte SFCs will follow) credit imports referenced only
1586/// in markup that `oxc_semantic` cannot see. Names in the set are filtered
1587/// out of the `unused` result before it is built. Pass `&FxHashSet::default()`
1588/// when no template scan applies.
1589///
1590/// Note: `get_resolved_references` counts both value-context and type-context
1591/// references. A value import used only as a type annotation (`const x: Foo`)
1592/// will have a type-position reference and will NOT appear in the unused list.
1593/// This is correct: `import { Foo }` (without `type`) may be needed at runtime.
1594///
1595/// `import_equals_bindings` carries the `import X = require('./x')` locals the
1596/// extractor collected for the same program. They live outside `imports`, so
1597/// without them the require-derived lane would be absent on this path and such
1598/// a binding would keep crediting its target on a script the caller re-parses
1599/// (a Vue `generic="..."` block). Pass `&[]` when the program has none.
1600pub fn compute_import_binding_usage(
1601    program: &Program<'_>,
1602    imports: &[ImportInfo],
1603    import_equals_bindings: &[String],
1604    template_used: &rustc_hash::FxHashSet<String>,
1605) -> ImportBindingUsage {
1606    let mut semantic_usage = compute_semantic_usage_with_candidates(
1607        program,
1608        imports,
1609        import_equals_bindings,
1610        template_used,
1611        &rustc_hash::FxHashSet::default(),
1612    );
1613    // The exported form is exempt, exactly as it is on the extractor path, but
1614    // `export import X = require('./x')` is not a `<script setup>` spelling: no
1615    // name is exempted here.
1616    report_unreferenced_import_equals_bindings(&mut semantic_usage, &[]);
1617    semantic_usage.import_binding_usage
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::{
1623        advance_jsdoc_brace_stack, has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag,
1624        parse_source_to_module, scan_jsdoc_imports_in,
1625    };
1626    use fallow_types::discover::FileId;
1627    use fallow_types::extract::{ImportInfo, ImportedName};
1628    use std::path::Path;
1629
1630    #[test]
1631    fn has_public_tag_matches_bare_tag() {
1632        assert!(has_public_tag(" * @public"));
1633    }
1634
1635    #[test]
1636    fn has_public_tag_matches_api_public_variant() {
1637        assert!(has_public_tag(" * @api public"));
1638    }
1639
1640    #[test]
1641    fn has_public_tag_rejects_partial_word() {
1642        assert!(!has_public_tag(" * @publicly"));
1643    }
1644
1645    #[test]
1646    fn has_public_tag_rejects_at_apipublic() {
1647        assert!(!has_public_tag(" * @apipublic"));
1648    }
1649
1650    #[test]
1651    fn has_public_tag_rejects_missing_at() {
1652        assert!(!has_public_tag(" * public"));
1653    }
1654
1655    #[test]
1656    fn has_internal_tag_matches_bare_tag() {
1657        assert!(has_internal_tag(" * @internal"));
1658    }
1659
1660    #[test]
1661    fn has_internal_tag_rejects_partial_word() {
1662        assert!(!has_internal_tag(" * @internalizer"));
1663    }
1664
1665    #[test]
1666    fn has_internal_tag_rejects_missing_at() {
1667        assert!(!has_internal_tag(" * internal"));
1668    }
1669
1670    #[test]
1671    fn has_beta_tag_matches_bare_tag() {
1672        assert!(has_beta_tag(" * @beta"));
1673    }
1674
1675    #[test]
1676    fn has_beta_tag_rejects_partial_word() {
1677        assert!(!has_beta_tag(" * @betaware"));
1678    }
1679
1680    #[test]
1681    fn has_beta_tag_rejects_missing_at() {
1682        assert!(!has_beta_tag(" * beta"));
1683    }
1684
1685    #[test]
1686    fn alpha_tag_standalone() {
1687        assert!(has_alpha_tag("@alpha"));
1688    }
1689
1690    #[test]
1691    fn alpha_tag_with_text() {
1692        assert!(has_alpha_tag("@alpha Some description"));
1693    }
1694
1695    #[test]
1696    fn alpha_tag_not_prefix() {
1697        assert!(!has_alpha_tag("@alphabet"));
1698    }
1699
1700    #[test]
1701    fn has_alpha_tag_rejects_missing_at() {
1702        assert!(!has_alpha_tag(" * alpha"));
1703    }
1704
1705    fn scan(body: &str) -> Vec<ImportInfo> {
1706        let mut imports = Vec::new();
1707        scan_jsdoc_imports_in(body, &mut imports);
1708        imports
1709    }
1710
1711    #[test]
1712    fn scan_jsdoc_single_import_with_member() {
1713        let imports = scan(" * @param foo {import('./types').Foo}");
1714        assert_eq!(imports.len(), 1);
1715        assert_eq!(imports[0].source, "./types");
1716        assert_eq!(
1717            imports[0].imported_name,
1718            ImportedName::Named("Foo".to_string())
1719        );
1720        assert!(imports[0].is_type_only);
1721        assert!(imports[0].local_name.is_empty());
1722    }
1723
1724    #[test]
1725    fn script_auto_import_candidates_capture_zero_import_value_refs() {
1726        let info = parse_source_to_module(
1727            FileId(0),
1728            Path::new("pages/index.ts"),
1729            r"
1730                useCounter();
1731                const price = formatPrice(10);
1732                const localOnly = () => null;
1733                localOnly();
1734                type Local = UseTypeOnly;
1735            ",
1736            0,
1737            false,
1738        );
1739
1740        assert!(
1741            info.auto_import_candidates
1742                .contains(&"formatPrice".to_string())
1743        );
1744        assert!(
1745            info.auto_import_candidates
1746                .contains(&"useCounter".to_string())
1747        );
1748        assert!(
1749            !info
1750                .auto_import_candidates
1751                .contains(&"UseTypeOnly".to_string())
1752        );
1753        assert!(
1754            !info
1755                .auto_import_candidates
1756                .contains(&"localOnly".to_string())
1757        );
1758    }
1759
1760    #[test]
1761    fn script_auto_import_candidates_skip_explicit_imports() {
1762        let info = parse_source_to_module(
1763            FileId(0),
1764            Path::new("pages/index.ts"),
1765            "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1766            0,
1767            false,
1768        );
1769
1770        assert!(
1771            !info
1772                .auto_import_candidates
1773                .contains(&"useCounter".to_string())
1774        );
1775        assert!(
1776            info.auto_import_candidates
1777                .contains(&"useOther".to_string())
1778        );
1779    }
1780
1781    #[test]
1782    fn scan_jsdoc_double_quoted_path() {
1783        let imports = scan(r#" * @type {import("./types").Foo}"#);
1784        assert_eq!(imports.len(), 1);
1785        assert_eq!(imports[0].source, "./types");
1786    }
1787
1788    #[test]
1789    fn scan_jsdoc_multiple_imports_in_same_body() {
1790        let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1791        assert_eq!(imports.len(), 2);
1792        assert_eq!(imports[0].source, "./a");
1793        assert_eq!(imports[1].source, "./b");
1794    }
1795
1796    #[test]
1797    fn scan_jsdoc_union_annotation_captures_both_members() {
1798        let imports = scan(" * @type {import('./a').A | import('./b').B}");
1799        assert_eq!(imports.len(), 2);
1800        assert_eq!(
1801            imports[0].imported_name,
1802            ImportedName::Named("A".to_string())
1803        );
1804        assert_eq!(
1805            imports[1].imported_name,
1806            ImportedName::Named("B".to_string())
1807        );
1808    }
1809
1810    #[test]
1811    fn scan_jsdoc_nested_member_uses_first_segment() {
1812        let imports = scan(" * @type {import('./types').ns.Foo}");
1813        assert_eq!(imports.len(), 1);
1814        assert_eq!(
1815            imports[0].imported_name,
1816            ImportedName::Named("ns".to_string())
1817        );
1818    }
1819
1820    #[test]
1821    fn scan_jsdoc_parent_relative_path() {
1822        let imports = scan(" * @type {import('../lib/types.js').Foo}");
1823        assert_eq!(imports.len(), 1);
1824        assert_eq!(imports[0].source, "../lib/types.js");
1825    }
1826
1827    #[test]
1828    fn scan_jsdoc_bare_package_specifier() {
1829        let imports = scan(" * @type {import('@scope/pkg').Client}");
1830        assert_eq!(imports.len(), 1);
1831        assert_eq!(imports[0].source, "@scope/pkg");
1832        assert_eq!(
1833            imports[0].imported_name,
1834            ImportedName::Named("Client".to_string())
1835        );
1836    }
1837
1838    #[test]
1839    fn scan_jsdoc_without_member_is_side_effect() {
1840        let imports = scan(" * @type {import('./types')}");
1841        assert_eq!(imports.len(), 1);
1842        assert_eq!(imports[0].source, "./types");
1843        assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1844        assert!(imports[0].is_type_only);
1845    }
1846
1847    #[test]
1848    fn scan_jsdoc_empty_path_is_skipped() {
1849        let imports = scan(" * @type {import('').Foo}");
1850        assert!(imports.is_empty());
1851    }
1852
1853    #[test]
1854    fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1855        let imports = scan(" * @type {import('./truncated");
1856        assert!(imports.is_empty());
1857    }
1858
1859    #[test]
1860    fn scan_jsdoc_missing_closing_paren_is_skipped() {
1861        let imports = scan(" * @type {import('./types'.Foo}");
1862        assert!(imports.is_empty());
1863    }
1864
1865    #[test]
1866    fn scan_jsdoc_whitespace_between_paren_and_dot() {
1867        let imports = scan(" * @type {import('./types') .Foo}");
1868        assert_eq!(imports.len(), 1);
1869        assert_eq!(imports[0].source, "./types");
1870        assert_eq!(
1871            imports[0].imported_name,
1872            ImportedName::Named("Foo".to_string())
1873        );
1874    }
1875
1876    #[test]
1877    fn scan_jsdoc_whitespace_between_paren_and_quote() {
1878        let imports = scan(" * @type {import( './types').Foo}");
1879        assert_eq!(imports.len(), 1);
1880        assert_eq!(imports[0].source, "./types");
1881    }
1882
1883    #[test]
1884    fn scan_jsdoc_non_quote_after_paren_skipped() {
1885        let imports = scan(" * @type {import(foo).Bar}");
1886        assert!(imports.is_empty());
1887    }
1888
1889    #[test]
1890    fn scan_jsdoc_ignores_prose_with_import_word() {
1891        let imports = scan(" * This is an important note about imports.");
1892        assert!(imports.is_empty());
1893    }
1894
1895    #[test]
1896    fn scan_jsdoc_utf8_path_works() {
1897        let imports = scan(" * @type {import('./héllo').Foo}");
1898        assert_eq!(imports.len(), 1);
1899        assert_eq!(imports[0].source, "./héllo");
1900    }
1901
1902    #[test]
1903    fn scan_jsdoc_empty_body_is_empty() {
1904        assert!(scan("").is_empty());
1905    }
1906
1907    #[test]
1908    fn scan_jsdoc_no_import_in_body_is_empty() {
1909        assert!(scan(" * @param foo The foo parameter").is_empty());
1910    }
1911
1912    /// Regression: `import('...')` in JSDoc prose (outside any `{...}` brace
1913    /// group) is documentation/example syntax, not a type annotation. It must
1914    /// not be reported as a real import. Without this scoping check, files
1915    /// whose header doc documents which import forms they handle would surface
1916    /// false-positive unresolved-import findings.
1917    #[test]
1918    fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1919        // Mirrors the exact shape of an extractor's header doc that lists
1920        // import forms as bullet-point examples.
1921        let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1922        let imports = scan(body);
1923        assert!(
1924            imports.is_empty(),
1925            "prose import() should not be matched; got: {:?}",
1926            imports
1927                .iter()
1928                .map(|i| i.source.as_str())
1929                .collect::<Vec<_>>()
1930        );
1931    }
1932
1933    #[test]
1934    fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1935        let body = "\n * @example\n * const loaders = {\n *   admin: () => import('./prose')\n * }";
1936        let imports = scan(body);
1937        assert!(
1938            imports.is_empty(),
1939            "object-literal example import() should not be matched; got: {:?}",
1940            imports
1941                .iter()
1942                .map(|i| i.source.as_str())
1943                .collect::<Vec<_>>()
1944        );
1945    }
1946
1947    #[test]
1948    fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1949        let imports = scan(" * Use {import('./prose')} as an example string.");
1950        assert!(imports.is_empty());
1951    }
1952
1953    #[test]
1954    fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1955        let imports = scan("\n * @example\n * { import('./prose') }\n");
1956        assert!(imports.is_empty());
1957    }
1958
1959    /// A real `{@type ...}` annotation following a prose mention of `import()`
1960    /// must still be matched. The fix narrows scope without breaking the
1961    /// intended JSDoc type-annotation behavior.
1962    #[test]
1963    fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1964        let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1965        let imports = scan(body);
1966        assert_eq!(imports.len(), 1, "got: {imports:?}");
1967        assert_eq!(imports[0].source, "./real");
1968        assert_eq!(
1969            imports[0].imported_name,
1970            ImportedName::Named("Foo".to_string())
1971        );
1972    }
1973
1974    #[test]
1975    fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1976        let body = "\n * @returns {\n *   import('./real').Foo\n * }";
1977        let imports = scan(body);
1978        assert_eq!(imports.len(), 1, "got: {imports:?}");
1979        assert_eq!(imports[0].source, "./real");
1980        assert_eq!(
1981            imports[0].imported_name,
1982            ImportedName::Named("Foo".to_string())
1983        );
1984    }
1985
1986    #[test]
1987    fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1988        let body = "\n * @type\n * { import('./real').Foo }\n";
1989        let imports = scan(body);
1990        assert_eq!(imports.len(), 1, "got: {imports:?}");
1991        assert_eq!(imports[0].source, "./real");
1992        assert_eq!(
1993            imports[0].imported_name,
1994            ImportedName::Named("Foo".to_string())
1995        );
1996    }
1997
1998    #[test]
1999    fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
2000        let imports = scan(" * @satisfies {import('./real').Foo}");
2001        assert_eq!(imports.len(), 1, "got: {imports:?}");
2002        assert_eq!(imports[0].source, "./real");
2003        assert_eq!(
2004            imports[0].imported_name,
2005            ImportedName::Named("Foo".to_string())
2006        );
2007    }
2008
2009    #[test]
2010    fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
2011        let imports = scan(" * @template {import('./real').Foo} T");
2012        assert_eq!(imports.len(), 1, "got: {imports:?}");
2013        assert_eq!(imports[0].source, "./real");
2014        assert_eq!(
2015            imports[0].imported_name,
2016            ImportedName::Named("Foo".to_string())
2017        );
2018    }
2019
2020    #[test]
2021    fn scan_jsdoc_enum_type_tag_is_still_matched() {
2022        let imports = scan(" * @enum {import('./real').Foo}");
2023        assert_eq!(imports.len(), 1, "got: {imports:?}");
2024        assert_eq!(imports[0].source, "./real");
2025        assert_eq!(
2026            imports[0].imported_name,
2027            ImportedName::Named("Foo".to_string())
2028        );
2029    }
2030
2031    #[test]
2032    fn scan_jsdoc_appends_to_existing_imports() {
2033        let mut imports = vec![ImportInfo {
2034            source: "existing".to_string(),
2035            imported_name: ImportedName::Default,
2036            local_name: "existing".to_string(),
2037            is_type_only: false,
2038            is_type_only_star: false,
2039            from_style: false,
2040            span: oxc_span::Span::default(),
2041            source_span: oxc_span::Span::default(),
2042        }];
2043        scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
2044        assert_eq!(imports.len(), 2);
2045        assert_eq!(imports[0].source, "existing");
2046        assert_eq!(imports[1].source, "./new");
2047    }
2048
2049    #[test]
2050    fn scan_jsdoc_ident_boundary_stops_at_bracket() {
2051        let imports = scan(" * @type {import('./t').Abc}");
2052        assert_eq!(imports.len(), 1);
2053        assert_eq!(
2054            imports[0].imported_name,
2055            ImportedName::Named("Abc".to_string())
2056        );
2057    }
2058
2059    #[test]
2060    fn scan_jsdoc_empty_member_name_is_skipped() {
2061        let imports = scan(" * @type {import('./x').}");
2062        assert!(imports.is_empty());
2063    }
2064
2065    #[test]
2066    fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
2067        // Regression for the issue #1843 follow-up: the enclosing-brace lookup
2068        // is maintained incrementally across the whole comment rather than
2069        // rescanning every prefix. A comment packed with many `import(...)` type
2070        // refs must still extract exactly one import per `{...}` type group, in
2071        // order, with the same paths and member names as before.
2072        use std::fmt::Write as _;
2073        let mut body = String::from("/**\n");
2074        for i in 0..200 {
2075            let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
2076        }
2077        // A prose `import(` outside any type brace group and a nested brace
2078        // must not add spurious imports or shift the enclosing-brace tracking.
2079        body.push_str(" * @remarks import('./ignored') appears in prose here\n");
2080        body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
2081        body.push_str(" */\n");
2082
2083        let imports = scan(&body);
2084        assert_eq!(imports.len(), 201, "got: {imports:?}");
2085        for (i, import) in imports.iter().take(200).enumerate() {
2086            assert_eq!(import.source, format!("./m{i}"));
2087            assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
2088            assert!(import.is_type_only);
2089            assert!(import.local_name.is_empty());
2090        }
2091        // The nested-brace occurrence still resolves against its enclosing group.
2092        assert_eq!(imports[200].source, "./deep");
2093        assert_eq!(
2094            imports[200].imported_name,
2095            ImportedName::Named("D".to_string())
2096        );
2097    }
2098
2099    #[test]
2100    fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
2101        // Cross-checks the incremental brace stack against an independent
2102        // offset-zero rescan over the full prefix, on inputs where the
2103        // `import(` cursor skips over intervening braces (issue #1843 follow-up).
2104        let cases = [
2105            " * @type {import('./a').A} and {plain} then {import('./b').B}",
2106            " * @remarks { import('./skip') } @param x {import('./c').C}",
2107            " * text } stray close { import('./d').D } trailing",
2108            " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
2109        ];
2110        for body in cases {
2111            let bytes = body.as_bytes();
2112            let mut cursor = 0;
2113            while let Some(rel) = body[cursor..].find("import(") {
2114                let import_pos = cursor + rel;
2115                // Independent offset-zero rescan reproducing the old helper.
2116                let mut fresh = Vec::new();
2117                for (idx, &b) in bytes[..import_pos].iter().enumerate() {
2118                    match b {
2119                        b'{' => fresh.push(idx),
2120                        b'}' => {
2121                            fresh.pop();
2122                        }
2123                        _ => {}
2124                    }
2125                }
2126                let mut stack = Vec::new();
2127                let mut scanned = 0;
2128                advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
2129                assert_eq!(
2130                    stack.last().copied(),
2131                    fresh.last().copied(),
2132                    "enclosing brace mismatch at {import_pos} in {body:?}"
2133                );
2134                cursor = import_pos + "import(".len();
2135            }
2136        }
2137    }
2138}