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