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