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