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