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