Skip to main content

fallow_extract/
parse.rs

1use std::path::Path;
2
3use oxc_allocator::Allocator;
4use oxc_ast::ast::{Comment, Program};
5use oxc_ast_visit::Visit;
6use oxc_parser::Parser;
7use oxc_span::SourceType;
8
9use crate::ExportInfo;
10use crate::ModuleInfo;
11use crate::astro::{is_astro_file, parse_astro_to_module};
12use crate::css::{is_css_file, parse_css_to_module};
13use crate::glimmer::{is_glimmer_file, strip_glimmer_templates};
14use crate::graphql::{is_graphql_file, parse_graphql_to_module};
15use crate::html::{is_html_file, parse_html_to_module_with_complexity};
16use crate::mdx::{is_mdx_file, parse_mdx_to_module};
17use crate::sfc::{is_sfc_file, parse_sfc_to_module};
18use crate::visitor::{ModuleInfoExtractor, RouteLoadHarvestMode};
19use fallow_types::discover::FileId;
20use fallow_types::extract::{FlagUse, FunctionComplexity, ImportInfo, VisibilityTag};
21
22struct JsxRetryParse {
23    extractor: ModuleInfoExtractor,
24    semantic_usage: SemanticUsage,
25    complexity: Vec<FunctionComplexity>,
26    flag_uses: Vec<FlagUse>,
27    parsed_suppressions: crate::suppress::ParsedSuppressions,
28}
29
30fn source_type_for_path(path: &Path) -> SourceType {
31    match path.extension().and_then(|ext| ext.to_str()) {
32        Some("gts") => SourceType::ts(),
33        Some("gjs") => SourceType::mjs(),
34        _ => SourceType::from_path(path).unwrap_or_default(),
35    }
36}
37
38/// Parse source text into a [`ModuleInfo`].
39///
40/// When `need_complexity` is false the per-function complexity visitor is
41/// skipped, saving one full AST walk per file.  The dead-code analysis
42/// pipeline never consumes complexity data, so callers that only need
43/// imports/exports should pass `false`.
44pub fn parse_source_to_module(
45    file_id: FileId,
46    path: &Path,
47    source: &str,
48    content_hash: u64,
49    need_complexity: bool,
50) -> ModuleInfo {
51    let mut module =
52        parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
53    module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
54    module.iconify_icon_names = crate::iconify::extract_iconify_icon_names(path, source);
55    // Keep this post-parse guard as defense in depth. The extractor is also
56    // mode-gated before the AST walk, so incompatible route producer names never
57    // enter the shared cached field in the first place.
58    if route_load_harvest_mode_for_path(path) == RouteLoadHarvestMode::None {
59        module.load_return_keys = Vec::new();
60        module.has_unharvestable_load = false;
61    }
62    module
63}
64
65/// Whether a file is a SvelteKit page-load producer:
66/// `+page.{ts,server.ts,js,server.js}`. Layout loads (`+layout(.server).{ts,js}`)
67/// are out of scope for v1 (cut A). The leading `+` is a SvelteKit-only
68/// filename convention, so no ordinary module matches.
69fn is_sveltekit_page_load_file(path: &Path) -> bool {
70    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
71        return false;
72    };
73    matches!(
74        name,
75        "+page.ts" | "+page.server.ts" | "+page.js" | "+page.server.js"
76    )
77}
78
79fn route_load_harvest_mode_for_path(path: &Path) -> RouteLoadHarvestMode {
80    if is_sveltekit_page_load_file(path) {
81        return RouteLoadHarvestMode::SvelteKitPage;
82    }
83    if is_conventional_route_loader_file(path) {
84        return RouteLoadHarvestMode::ConventionalRoute;
85    }
86    RouteLoadHarvestMode::None
87}
88
89fn is_conventional_route_loader_file(path: &Path) -> bool {
90    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
91        return false;
92    };
93    if name.starts_with('+') {
94        return false;
95    }
96    if !matches!(
97        path.extension().and_then(|ext| ext.to_str()),
98        Some("ts" | "tsx" | "js" | "jsx")
99    ) {
100        return false;
101    }
102    if matches!(name, "root.ts" | "root.tsx" | "root.js" | "root.jsx")
103        && path
104            .parent()
105            .and_then(|parent| parent.file_name())
106            .and_then(|part| part.to_str())
107            .is_some_and(|part| matches!(part, "app" | "src"))
108    {
109        return true;
110    }
111    path_has_route_dir(path, "app") || path_has_route_dir(path, "src")
112}
113
114fn path_has_route_dir(path: &Path, app_dir: &str) -> bool {
115    let mut previous = None;
116    for part in path.components().filter_map(|c| c.as_os_str().to_str()) {
117        if previous == Some(app_dir) && part == "routes" {
118            return true;
119        }
120        previous = Some(part);
121    }
122    false
123}
124
125fn parse_source_to_module_inner(
126    file_id: FileId,
127    path: &Path,
128    source: &str,
129    content_hash: u64,
130    need_complexity: bool,
131) -> ModuleInfo {
132    let source = crate::strip_bom(source);
133    if let Some(module) =
134        parse_non_js_source_to_module(file_id, path, source, content_hash, need_complexity)
135    {
136        return module;
137    }
138
139    let stripped_glimmer_source = is_glimmer_file(path)
140        .then(|| strip_glimmer_templates(source))
141        .flatten();
142    let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
143    let source_type = source_type_for_path(path);
144    let allocator = Allocator::default();
145    let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
146
147    let mut parsed_suppressions =
148        crate::suppress::parse_suppressions(&parser_return.program.comments, source);
149
150    let (mut extractor, mut semantic_usage) =
151        build_primary_extractor(&parser_return.program, path, source, source_type);
152
153    let line_offsets = fallow_types::extract::compute_line_offsets(source);
154
155    let (mut complexity, mut flag_uses) = compute_primary_complexity_and_flags(
156        &parser_return.program,
157        parser_source,
158        &extractor.inline_template_findings,
159        &line_offsets,
160        need_complexity,
161    );
162
163    apply_jsx_retry_or_jsdoc(
164        &JsxRetryOrJsdocInput {
165            path,
166            parser_source,
167            source_type,
168            need_complexity,
169            line_offsets: &line_offsets,
170            comments: &parser_return.program.comments,
171            source,
172        },
173        &mut ParseOutputs {
174            extractor: &mut extractor,
175            semantic_usage: &mut semantic_usage,
176            complexity: &mut complexity,
177            flag_uses: &mut flag_uses,
178            parsed_suppressions: &mut parsed_suppressions,
179        },
180    );
181
182    assemble_module_info(ModuleAssemblyInput {
183        extractor,
184        file_id,
185        content_hash,
186        parsed_suppressions,
187        semantic_usage,
188        line_offsets,
189        complexity,
190        flag_uses,
191    })
192}
193
194/// Inputs shared by the JSX retry and the fallback JSDoc enrichment pass.
195struct JsxRetryOrJsdocInput<'a> {
196    path: &'a Path,
197    parser_source: &'a str,
198    source_type: SourceType,
199    need_complexity: bool,
200    line_offsets: &'a [u32],
201    comments: &'a [Comment],
202    source: &'a str,
203}
204
205struct ModuleAssemblyInput {
206    extractor: ModuleInfoExtractor,
207    file_id: FileId,
208    content_hash: u64,
209    parsed_suppressions: crate::suppress::ParsedSuppressions,
210    semantic_usage: SemanticUsage,
211    line_offsets: Vec<u32>,
212    complexity: Vec<FunctionComplexity>,
213    flag_uses: Vec<FlagUse>,
214}
215
216/// Build the primary extractor: run the AST walk (JSX-gated), fold in Glimmer
217/// template usage, and compute import-binding semantic usage.
218fn build_primary_extractor(
219    program: &Program<'_>,
220    path: &Path,
221    source: &str,
222    source_type: SourceType,
223) -> (ModuleInfoExtractor, SemanticUsage) {
224    let mut extractor = ModuleInfoExtractor::new();
225    extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(path));
226    // Gate the React/JSX structural walk on a JSX-capable parse so it is a
227    // no-op on non-JSX files (perf: the `audit` hot path on non-React repos
228    // must not regress).
229    extractor.jsx_capable = source_type.is_jsx();
230    extractor.visit_program(program);
231    extractor.resolve_pending_local_export_specifiers();
232
233    let template_used_imports =
234        collect_glimmer_template_into_extractor(&mut extractor, path, source);
235    let semantic_usage =
236        compute_semantic_usage(program, &extractor.imports, &template_used_imports);
237    (extractor, semantic_usage)
238}
239
240/// Compute per-function complexity (with inline-template findings folded in) and
241/// feature-flag uses for the primary parse, honoring `need_complexity`.
242fn compute_primary_complexity_and_flags(
243    program: &Program<'_>,
244    parser_source: &str,
245    inline_template_findings: &[crate::visitor::InlineTemplateFinding],
246    line_offsets: &[u32],
247    need_complexity: bool,
248) -> (Vec<FunctionComplexity>, Vec<FlagUse>) {
249    let mut complexity = if need_complexity {
250        crate::complexity::compute_complexity(program, parser_source, line_offsets)
251    } else {
252        Vec::new()
253    };
254    if need_complexity {
255        append_inline_template_complexity(&mut complexity, inline_template_findings, line_offsets);
256    }
257
258    let flag_uses = crate::flags::extract_flags(
259        program,
260        line_offsets,
261        &[],   // built-in patterns only at parse time
262        &[],   // built-in prefixes only at parse time
263        false, // config object heuristics off at parse time (opt-in via config)
264    );
265    (complexity, flag_uses)
266}
267
268/// Mutable references to the primary-parse outputs a JSX retry replaces wholesale.
269struct ParseOutputs<'a> {
270    extractor: &'a mut ModuleInfoExtractor,
271    semantic_usage: &'a mut SemanticUsage,
272    complexity: &'a mut Vec<FunctionComplexity>,
273    flag_uses: &'a mut Vec<FlagUse>,
274    parsed_suppressions: &'a mut crate::suppress::ParsedSuppressions,
275}
276
277/// Run the JSX retry parse: when it improves extraction, overwrite every
278/// primary-parse output in place; otherwise apply JSDoc tags to the primary
279/// extractor. The retry's own parse already applies JSDoc tags.
280fn apply_jsx_retry_or_jsdoc(input: &JsxRetryOrJsdocInput<'_>, outputs: &mut ParseOutputs<'_>) {
281    let retry_input = JsxRetryInput {
282        path: input.path,
283        source: input.source,
284        parser_source: input.parser_source,
285        source_type: input.source_type,
286        total_extracted: outputs.extractor.exports.len()
287            + outputs.extractor.imports.len()
288            + outputs.extractor.re_exports.len(),
289        need_complexity: input.need_complexity,
290        line_offsets: input.line_offsets,
291    };
292    let Some(retry) = parse_with_jsx_retry(&retry_input) else {
293        apply_jsdoc_tags_to_extractor(&mut *outputs.extractor, input.comments, input.source);
294        return;
295    };
296    *outputs.extractor = retry.extractor;
297    *outputs.semantic_usage = retry.semantic_usage;
298    *outputs.complexity = retry.complexity;
299    *outputs.flag_uses = retry.flag_uses;
300    *outputs.parsed_suppressions = retry.parsed_suppressions;
301}
302
303/// Apply JSDoc visibility tags and JSDoc `import()` type references to the
304/// extractor's exports/imports for the primary (non-retry) parse.
305fn apply_jsdoc_tags_to_extractor(
306    extractor: &mut ModuleInfoExtractor,
307    comments: &[Comment],
308    source: &str,
309) {
310    apply_jsdoc_visibility_tags(&mut extractor.exports, comments, source);
311    extract_jsdoc_import_types(&mut extractor.imports, comments, source);
312}
313
314/// Convert the finalized extractor into a `ModuleInfo`, attaching semantic-usage,
315/// line-offset, complexity, and flag-use side data.
316fn assemble_module_info(input: ModuleAssemblyInput) -> ModuleInfo {
317    let ModuleAssemblyInput {
318        extractor,
319        file_id,
320        content_hash,
321        parsed_suppressions,
322        semantic_usage,
323        line_offsets,
324        complexity,
325        flag_uses,
326    } = input;
327    let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
328    info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
329    info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
330    info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
331    info.auto_import_candidates = semantic_usage.auto_import_candidates;
332    info.line_offsets = line_offsets;
333    info.complexity = complexity;
334    info.flag_uses = flag_uses;
335    info
336}
337
338struct JsxRetryInput<'a> {
339    path: &'a Path,
340    source: &'a str,
341    parser_source: &'a str,
342    source_type: SourceType,
343    total_extracted: usize,
344    need_complexity: bool,
345    line_offsets: &'a [u32],
346}
347
348fn parse_with_jsx_retry(input: &JsxRetryInput<'_>) -> Option<JsxRetryParse> {
349    if input.total_extracted != 0 || input.source.len() <= 100 || input.source_type.is_jsx() {
350        return None;
351    }
352
353    let jsx_type = if input.source_type.is_typescript() {
354        SourceType::tsx()
355    } else {
356        SourceType::jsx()
357    };
358    let allocator = Allocator::default();
359    let retry_return = Parser::new(&allocator, input.parser_source, jsx_type).parse();
360    let mut extractor = ModuleInfoExtractor::new();
361    extractor.set_route_load_harvest_mode(route_load_harvest_mode_for_path(input.path));
362    // The retry re-parses a `.js`/`.ts` file that turned out to contain JSX, so
363    // the JSX structural walk applies here too.
364    extractor.jsx_capable = true;
365    extractor.visit_program(&retry_return.program);
366    extractor.resolve_pending_local_export_specifiers();
367    let retry_total =
368        extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
369    if retry_total <= input.total_extracted {
370        return None;
371    }
372
373    let template_used_imports =
374        collect_glimmer_template_into_extractor(&mut extractor, input.path, input.source);
375    let semantic_usage = compute_semantic_usage(
376        &retry_return.program,
377        &extractor.imports,
378        &template_used_imports,
379    );
380    let complexity = retry_complexity(
381        input.need_complexity,
382        &retry_return.program,
383        input.parser_source,
384        input.line_offsets,
385        &extractor,
386    );
387    let flag_uses =
388        crate::flags::extract_flags(&retry_return.program, input.line_offsets, &[], &[], false);
389    let parsed_suppressions =
390        crate::suppress::parse_suppressions(&retry_return.program.comments, input.source);
391    apply_jsdoc_visibility_tags(
392        &mut extractor.exports,
393        &retry_return.program.comments,
394        input.source,
395    );
396    extract_jsdoc_import_types(
397        &mut extractor.imports,
398        &retry_return.program.comments,
399        input.source,
400    );
401    Some(JsxRetryParse {
402        extractor,
403        semantic_usage,
404        complexity,
405        flag_uses,
406        parsed_suppressions,
407    })
408}
409
410fn retry_complexity(
411    need_complexity: bool,
412    program: &Program<'_>,
413    parser_source: &str,
414    line_offsets: &[u32],
415    extractor: &ModuleInfoExtractor,
416) -> Vec<FunctionComplexity> {
417    if !need_complexity {
418        return Vec::new();
419    }
420    let mut complexity =
421        crate::complexity::compute_complexity(program, parser_source, line_offsets);
422    append_inline_template_complexity(
423        &mut complexity,
424        &extractor.inline_template_findings,
425        line_offsets,
426    );
427    complexity
428}
429
430fn parse_non_js_source_to_module(
431    file_id: FileId,
432    path: &Path,
433    source: &str,
434    content_hash: u64,
435    need_complexity: bool,
436) -> Option<ModuleInfo> {
437    if is_sfc_file(path) {
438        return Some(parse_sfc_to_module(
439            file_id,
440            path,
441            source,
442            content_hash,
443            need_complexity,
444        ));
445    }
446    if is_astro_file(path) {
447        return Some(parse_astro_to_module(
448            file_id,
449            source,
450            content_hash,
451            need_complexity,
452        ));
453    }
454    if is_mdx_file(path) {
455        return Some(parse_mdx_to_module(file_id, source, content_hash));
456    }
457    if is_css_file(path) {
458        return Some(parse_css_to_module(file_id, path, source, content_hash));
459    }
460    if is_graphql_file(path) {
461        return Some(parse_graphql_to_module(file_id, source, content_hash));
462    }
463    if is_html_file(path) {
464        return Some(parse_html_to_module_with_complexity(
465            file_id,
466            source,
467            content_hash,
468            need_complexity,
469        ));
470    }
471    None
472}
473
474/// Scan Glimmer `<template>...</template>` blocks in a `.gts` / `.gjs` file
475/// and fold the result directly into `extractor`. Returns the set of import
476/// local names that the template body credits, so
477/// `compute_import_binding_usage` can skip them when building the unused list.
478///
479/// Mirrors the Angular inline-template path in
480/// `visitor/visit_impl.rs::visit_class`, which pushes
481/// `collect_angular_template_refs(...)` results straight onto
482/// `self.member_accesses`. The Glimmer scan can't run inside the JS visitor
483/// because template bodies are blanked by `strip_glimmer_templates` before
484/// the JS parse. The un-stripped source is only available here in
485/// `parse.rs`, so this is the earliest point we can fold the result in.
486///
487/// `extractor.member_accesses` receives every emitted `MemberAccess`
488/// (including `this.<member>` chain hops that survive even when there are
489/// zero imports; class-member tracking still needs them). Bindings the
490/// template credits are returned, not pushed; the caller threads them into
491/// `compute_import_binding_usage`'s skip-set so the `unused` vector never
492/// names them in the first place. This replaces the previous
493/// `apply_glimmer_template_usage` post-construction `info` mutation and
494/// the `retain` it performed against `unused_import_bindings`.
495fn collect_glimmer_template_into_extractor(
496    extractor: &mut ModuleInfoExtractor,
497    path: &Path,
498    source: &str,
499) -> rustc_hash::FxHashSet<String> {
500    use rustc_hash::FxHashSet;
501
502    if !is_glimmer_file(path) {
503        return FxHashSet::default();
504    }
505    let template_ranges = crate::glimmer::find_template_ranges(source);
506    if template_ranges.is_empty() {
507        return FxHashSet::default();
508    }
509
510    let imported_bindings: FxHashSet<String> = extractor
511        .imports
512        .iter()
513        .filter(|import| !import.local_name.is_empty())
514        .map(|import| import.local_name.clone())
515        .collect();
516
517    let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
518        source,
519        &template_ranges,
520        &imported_bindings,
521    );
522    extractor.member_accesses.extend(usage.member_accesses);
523    usage.used_bindings
524}
525
526/// Synthesise `<template>` complexity findings for inline `@Component({ template: \`...\` })`
527/// decorators captured by the visitor pass.
528///
529/// The template-complexity scanner returns line/col relative to the template
530/// body itself; we replace those with the host file's line/col for the
531/// matched `@Component`/`@Directive` decorator. Anchoring at the decorator
532/// (rather than the literal's opening backtick) gives a useful jump-to-source
533/// landing inside the decorator block and lets `// fallow-ignore-next-line
534/// complexity` comments placed directly above the decorator suppress the
535/// finding through the existing health-side check, with no extra plumbing.
536fn append_inline_template_complexity(
537    complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
538    findings: &[crate::visitor::InlineTemplateFinding],
539    line_offsets: &[u32],
540) {
541    for finding in findings {
542        let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
543            &finding.template_source,
544        ) else {
545            continue;
546        };
547        let (line, col) =
548            fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
549        fc.line = line;
550        fc.col = col;
551        complexity.push(fc);
552    }
553}
554
555/// Apply JSDoc visibility tags (`@public`, `@internal`, `@alpha`, `@beta`) to exports by
556/// matching leading JSDoc comments.
557///
558/// `Comment.attached_to` points to the `export` keyword byte offset, while
559/// `ExportInfo.span` stores the identifier byte offset (e.g., `foo` in
560/// `export const foo`). This function bridges the gap: it collects visibility
561/// comment attachment offsets with their tag, then for each export finds the
562/// nearest preceding attachment point and validates it's part of the same
563/// export statement.
564fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
565    if exports.is_empty() || comments.is_empty() {
566        return;
567    }
568
569    let mut tag_offsets = collect_jsdoc_tag_offsets(comments, source);
570    if tag_offsets.is_empty() {
571        return;
572    }
573    tag_offsets.sort_unstable_by_key(|&(offset, _, _)| offset);
574
575    for export in exports.iter_mut() {
576        apply_visibility_tag_to_export(export, &tag_offsets, source);
577    }
578}
579
580/// Classify a JSDoc comment body into a visibility tag (and optional reason),
581/// or `None` when no recognized tag is present.
582fn classify_jsdoc_visibility_tag(text: &str) -> Option<(VisibilityTag, Option<String>)> {
583    if has_public_tag(text) {
584        Some((VisibilityTag::Public, None))
585    } else if has_internal_tag(text) {
586        Some((VisibilityTag::Internal, None))
587    } else if has_alpha_tag(text) {
588        Some((VisibilityTag::Alpha, None))
589    } else if has_beta_tag(text) {
590        Some((VisibilityTag::Beta, None))
591    } else {
592        let (has_expected_unused, reason) = expected_unused_tag(text);
593        has_expected_unused.then_some((VisibilityTag::ExpectedUnused, reason))
594    }
595}
596
597/// Collect `(attachment_offset, tag, reason)` triples for every JSDoc comment
598/// that carries a recognized visibility tag.
599fn collect_jsdoc_tag_offsets(
600    comments: &[Comment],
601    source: &str,
602) -> Vec<(u32, VisibilityTag, Option<String>)> {
603    let mut tag_offsets: Vec<(u32, VisibilityTag, Option<String>)> = Vec::new();
604    for comment in comments {
605        if !comment.is_jsdoc() {
606            continue;
607        }
608        let content_span = comment.content_span();
609        let start = content_span.start as usize;
610        let end = (content_span.end as usize).min(source.len());
611        if start >= end {
612            continue;
613        }
614        if let Some((tag, reason)) = classify_jsdoc_visibility_tag(&source[start..end]) {
615            tag_offsets.push((comment.attached_to, tag, reason));
616        }
617    }
618    tag_offsets
619}
620
621/// Apply the best-matching visibility tag to a single export: an exact
622/// attachment-offset hit, else the nearest preceding tag within the same
623/// `export` statement prefix.
624fn apply_visibility_tag_to_export(
625    export: &mut ExportInfo,
626    tag_offsets: &[(u32, VisibilityTag, Option<String>)],
627    source: &str,
628) {
629    if export.span.start == 0 && export.span.end == 0 {
630        return;
631    }
632
633    if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _, _)| o) {
634        export.visibility = tag_offsets[idx].1;
635        export
636            .expected_unused_reason
637            .clone_from(&tag_offsets[idx].2);
638        return;
639    }
640
641    let idx = tag_offsets.partition_point(|&(o, _, _)| o <= export.span.start);
642    if idx > 0 {
643        let (offset, tag, ref reason) = tag_offsets[idx - 1];
644        let offset = offset as usize;
645        let export_start = export.span.start as usize;
646        if offset < export_start && export_start <= source.len() {
647            let between = &source[offset..export_start];
648            if between.starts_with("export") && !between.contains(';') && !between.contains('}') {
649                export.visibility = tag;
650                export.expected_unused_reason.clone_from(reason);
651            }
652        }
653    }
654}
655
656/// Check if a JSDoc comment body contains an `@internal` tag.
657fn has_internal_tag(comment_text: &str) -> bool {
658    for (i, _) in comment_text.match_indices("@internal") {
659        let after = i + "@internal".len();
660        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
661            return true;
662        }
663    }
664    false
665}
666
667/// Check if a JSDoc comment body contains a `@beta` tag.
668fn has_beta_tag(comment_text: &str) -> bool {
669    for (i, _) in comment_text.match_indices("@beta") {
670        let after = i + "@beta".len();
671        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
672            return true;
673        }
674    }
675    false
676}
677
678/// Check if a JSDoc comment body contains an `@alpha` tag.
679fn has_alpha_tag(comment_text: &str) -> bool {
680    for (i, _) in comment_text.match_indices("@alpha") {
681        let after = i + "@alpha".len();
682        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
683            return true;
684        }
685    }
686    false
687}
688
689fn split_jsdoc_reason(rest: &str) -> Option<String> {
690    for (idx, _) in rest.match_indices("--") {
691        let before_ok = idx == 0
692            || rest[..idx]
693                .chars()
694                .next_back()
695                .is_some_and(char::is_whitespace);
696        let after_idx = idx + 2;
697        let after_ok = after_idx == rest.len()
698            || rest[after_idx..]
699                .chars()
700                .next()
701                .is_some_and(char::is_whitespace);
702        if before_ok && after_ok {
703            let reason = rest[after_idx..].trim();
704            return if reason.is_empty() {
705                None
706            } else {
707                Some(reason.to_string())
708            };
709        }
710    }
711
712    None
713}
714
715/// Return whether an `@expected-unused` tag is present and its optional reason.
716fn expected_unused_tag(comment_text: &str) -> (bool, Option<String>) {
717    for (i, _) in comment_text.match_indices("@expected-unused") {
718        let after = i + "@expected-unused".len();
719        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
720            return (true, split_jsdoc_reason(&comment_text[after..]));
721        }
722    }
723    (false, None)
724}
725
726/// Check if a byte is an identifier-continuation character (alphanumeric or `_`).
727const fn is_ident_char(b: u8) -> bool {
728    b.is_ascii_alphanumeric() || b == b'_'
729}
730
731/// Scan JSDoc comments for `import('./path').Member` type expressions and push
732/// them onto `imports` as type-only imports.
733///
734/// JSDoc supports referencing types from other modules via `import()` expressions
735/// embedded in tag annotations, e.g.:
736///
737/// ```js
738/// /**
739///  * @param foo {import('./types.js').Foo}
740///  * @returns {import('./types').Bar}
741///  */
742/// ```
743///
744/// Without this scanner, the referenced export (`Foo`, `Bar`) is flagged as
745/// unused because no ES `import` statement binds it. The synthesized
746/// `ImportInfo` has `is_type_only: true` and an empty `local_name` so it does
747/// not interfere with `compute_unused_import_bindings` (which skips imports
748/// with empty local names) and does not add a cyclic-dependency edge.
749///
750/// All JSDoc tag contexts (`@param`, `@returns`, `@type`, `@typedef`,
751/// `@callback`, etc.) use the same `{type}` annotation syntax, so scanning
752/// type-bearing brace groups covers every call site without treating prose
753/// examples as imports.
754fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
755    if comments.is_empty() {
756        return;
757    }
758
759    for comment in comments {
760        if !comment.is_jsdoc() {
761            continue;
762        }
763        let content_span = comment.content_span();
764        let start = content_span.start as usize;
765        let end = (content_span.end as usize).min(source.len());
766        if start >= end {
767            continue;
768        }
769        scan_jsdoc_imports_in(&source[start..end], imports);
770    }
771}
772
773/// Parse a single JSDoc comment body for `import('...').Member` expressions.
774///
775/// Matches both single and double quoted path literals and extracts the first
776/// identifier segment after `)\.` as the imported member name. Nested member
777/// access (`import('./x').ns.Foo`) yields `ns` as the imported name, which is
778/// correct for fallow's syntactic analysis since the resolver still adds the
779/// edge to the target module.
780fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
781    let bytes = body.as_bytes();
782    let mut cursor = 0;
783    // Brace-nesting stack (byte offsets of currently-open `{`) maintained
784    // incrementally as the cursor advances, so each `import(` occurrence reuses
785    // the enclosing-brace position instead of rescanning the whole prefix from
786    // offset 0. issue #1843 follow-up: turns the per-occurrence O(prefix) rescan
787    // in the old `enclosing_jsdoc_brace_start` into a single O(body) forward
788    // pass over the comment while staying byte-identical.
789    let mut brace_stack: Vec<usize> = Vec::new();
790    let mut scanned = 0;
791    while let Some(rel) = body[cursor..].find("import(") {
792        let import_pos = cursor + rel;
793        advance_jsdoc_brace_stack(bytes, &mut brace_stack, &mut scanned, import_pos);
794        if !is_inside_jsdoc_type_brace_group(bytes, import_pos, brace_stack.last().copied()) {
795            cursor = import_pos + "import(".len();
796            continue;
797        }
798        let open = import_pos + "import(".len();
799        match locate_jsdoc_import_path(body, bytes, open) {
800            JsdocImportScan::Stop => break,
801            JsdocImportScan::Skip(next) => {
802                cursor = next;
803            }
804            JsdocImportScan::Found { path, after_paren } => {
805                cursor = resolve_jsdoc_import(body, bytes, after_paren, path, imports);
806            }
807        }
808    }
809}
810
811/// Outcome of locating the path literal and closing paren of one JSDoc
812/// `import(...)` occurrence.
813enum JsdocImportScan<'a> {
814    /// Malformed or truncated; abandon the whole scan.
815    Stop,
816    /// Not a recoverable import here; resume scanning from this cursor.
817    Skip(usize),
818    /// A non-empty path was parsed; `after_paren` is the cursor past the `)`.
819    Found { path: &'a str, after_paren: usize },
820}
821
822/// Parse the quoted path literal following `import(` at `open` and locate the
823/// closing paren, returning where the caller should resume.
824fn locate_jsdoc_import_path<'a>(body: &'a str, bytes: &[u8], open: usize) -> JsdocImportScan<'a> {
825    if open >= bytes.len() {
826        return JsdocImportScan::Stop;
827    }
828    let mut i = open;
829    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
830        i += 1;
831    }
832    if i >= bytes.len() {
833        return JsdocImportScan::Stop;
834    }
835    let quote = bytes[i];
836    if quote != b'\'' && quote != b'"' {
837        return JsdocImportScan::Skip(open);
838    }
839    let path_start = i + 1;
840    let Some(rel_close) = body[path_start..].find(quote as char) else {
841        return JsdocImportScan::Stop;
842    };
843    let path_end = path_start + rel_close;
844    let path = &body[path_start..path_end];
845    if path.is_empty() {
846        return JsdocImportScan::Skip(path_end + 1);
847    }
848    let mut j = path_end + 1;
849    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
850        j += 1;
851    }
852    if j >= bytes.len() || bytes[j] != b')' {
853        return JsdocImportScan::Skip(path_end + 1);
854    }
855    j += 1;
856    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
857        j += 1;
858    }
859    JsdocImportScan::Found {
860        path,
861        after_paren: j,
862    }
863}
864
865/// Resolve the imported name after the `)` (member access -> `Named`, otherwise
866/// `SideEffect`), push the `ImportInfo`, and return the next scan cursor.
867fn resolve_jsdoc_import(
868    body: &str,
869    bytes: &[u8],
870    after_paren: usize,
871    path: &str,
872    imports: &mut Vec<ImportInfo>,
873) -> usize {
874    let mut j = after_paren;
875    if j >= bytes.len() || bytes[j] != b'.' {
876        imports.push(jsdoc_type_import(
877            path,
878            fallow_types::extract::ImportedName::SideEffect,
879        ));
880        return after_paren;
881    }
882    j += 1;
883    let name_start = j;
884    while j < bytes.len() && is_ident_char(bytes[j]) {
885        j += 1;
886    }
887    if name_start == j {
888        // No identifier after `.`: leave the cursor at the post-paren position,
889        // matching the original `continue` (which never updated `cursor` here).
890        return after_paren;
891    }
892    let member = &body[name_start..j];
893    imports.push(jsdoc_type_import(
894        path,
895        fallow_types::extract::ImportedName::Named(member.to_string()),
896    ));
897    j
898}
899
900/// Build a type-only `ImportInfo` for a JSDoc `import('...')` reference. Spans
901/// are defaulted because JSDoc imports carry no real source position.
902fn jsdoc_type_import(
903    source: &str,
904    imported_name: fallow_types::extract::ImportedName,
905) -> ImportInfo {
906    ImportInfo {
907        source: source.to_string(),
908        imported_name,
909        local_name: String::new(),
910        is_type_only: true,
911        from_style: false,
912        span: oxc_span::Span::default(),
913        source_span: oxc_span::Span::default(),
914    }
915}
916
917/// Returns true when byte index `pos` falls inside a JSDoc type-expression
918/// brace group. Prose examples can contain ordinary JavaScript braces, so the
919/// enclosing brace must be tied to a JSDoc type tag. `open_brace` is the
920/// innermost enclosing `{` offset (or `None` when `pos` is at brace depth zero),
921/// supplied by the caller's incrementally-maintained brace stack.
922fn is_inside_jsdoc_type_brace_group(body: &[u8], pos: usize, open_brace: Option<usize>) -> bool {
923    let Some(open_brace) = open_brace else {
924        return false;
925    };
926
927    let prefix = line_prefix_before(body, open_brace);
928    if jsdoc_line_prefix_has_type_tag(prefix) {
929        return true;
930    }
931
932    strip_jsdoc_line_prefix(prefix).is_empty()
933        && preceding_jsdoc_line_has_type_tag(body, open_brace)
934        && has_only_jsdoc_spacing_between(body, open_brace + 1, pos)
935}
936
937/// Advance the incrementally-maintained JSDoc brace stack from `*scanned` up to
938/// (but not including) `up_to`, pushing the offset of every `{` and popping on
939/// every `}`. Afterwards `stack.last()` is the innermost enclosing brace of
940/// `up_to`, identical to a fresh scan of `body[..up_to]` but amortized across
941/// every `import(` occurrence in the comment instead of rescanning each prefix
942/// from offset zero (issue #1843 follow-up).
943///
944/// `up_to` must not regress (the caller's `import(` cursor only moves forward);
945/// a non-advancing call is a no-op.
946fn advance_jsdoc_brace_stack(
947    body: &[u8],
948    stack: &mut Vec<usize>,
949    scanned: &mut usize,
950    up_to: usize,
951) {
952    let up_to = up_to.min(body.len());
953    while *scanned < up_to {
954        match body[*scanned] {
955            b'{' => stack.push(*scanned),
956            b'}' => {
957                stack.pop();
958            }
959            _ => {}
960        }
961        *scanned += 1;
962    }
963}
964
965fn line_prefix_before(body: &[u8], pos: usize) -> &str {
966    let start = body[..pos]
967        .iter()
968        .rposition(|&b| b == b'\n')
969        .map_or(0, |idx| idx + 1);
970    std::str::from_utf8(&body[start..pos]).unwrap_or_default()
971}
972
973fn strip_jsdoc_line_prefix(prefix: &str) -> &str {
974    let trimmed = prefix.trim_start();
975    trimmed
976        .strip_prefix('*')
977        .map_or(trimmed, |rest| rest.trim_start())
978}
979
980fn jsdoc_line_prefix_has_type_tag(prefix: &str) -> bool {
981    const TYPE_TAGS: [&str; 17] = [
982        "@arg",
983        "@argument",
984        "@augments",
985        "@callback",
986        "@enum",
987        "@extends",
988        "@implements",
989        "@param",
990        "@property",
991        "@prop",
992        "@return",
993        "@returns",
994        "@satisfies",
995        "@template",
996        "@this",
997        "@type",
998        "@typedef",
999    ];
1000
1001    let prefix = strip_jsdoc_line_prefix(prefix);
1002    TYPE_TAGS
1003        .iter()
1004        .any(|tag| contains_bare_jsdoc_tag(prefix, tag))
1005}
1006
1007fn contains_bare_jsdoc_tag(text: &str, tag: &str) -> bool {
1008    for (idx, _) in text.match_indices(tag) {
1009        let after = idx + tag.len();
1010        if after >= text.len() || !is_ident_char(text.as_bytes()[after]) {
1011            return true;
1012        }
1013    }
1014    false
1015}
1016
1017fn preceding_jsdoc_line_has_type_tag(body: &[u8], pos: usize) -> bool {
1018    let Some(line_end) = body[..pos].iter().rposition(|&b| b == b'\n') else {
1019        return false;
1020    };
1021
1022    let line_start = body[..line_end]
1023        .iter()
1024        .rposition(|&b| b == b'\n')
1025        .map_or(0, |idx| idx + 1);
1026
1027    std::str::from_utf8(&body[line_start..line_end]).is_ok_and(jsdoc_line_prefix_has_type_tag)
1028}
1029
1030fn has_only_jsdoc_spacing_between(body: &[u8], start: usize, end: usize) -> bool {
1031    let mut at_line_start = true;
1032    let mut i = start.min(body.len());
1033    let end = end.min(body.len());
1034    while i < end {
1035        match body[i] {
1036            b'\n' => {
1037                at_line_start = true;
1038                i += 1;
1039            }
1040            b'\r' | b'\t' | b' ' => {
1041                i += 1;
1042            }
1043            b'*' if at_line_start => {
1044                at_line_start = false;
1045                i += 1;
1046            }
1047            _ => return false,
1048        }
1049    }
1050    true
1051}
1052
1053/// Check if a JSDoc comment body contains a `@public` or `@api public` tag.
1054fn has_public_tag(comment_text: &str) -> bool {
1055    for (i, _) in comment_text.match_indices("@public") {
1056        let after = i + "@public".len();
1057        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
1058            return true;
1059        }
1060    }
1061    for (i, _) in comment_text.match_indices("@api") {
1062        let after = i + "@api".len();
1063        if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
1064            let rest = comment_text[after..].trim_start();
1065            if rest.starts_with("public") {
1066                let after_public = "public".len();
1067                if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
1068                    return true;
1069                }
1070            }
1071        }
1072    }
1073    false
1074}
1075
1076#[derive(Debug, Default, PartialEq, Eq)]
1077pub struct ImportBindingUsage {
1078    pub unused: Vec<String>,
1079    pub type_referenced: Vec<String>,
1080    pub value_referenced: Vec<String>,
1081}
1082
1083#[derive(Debug, Default, PartialEq, Eq)]
1084pub struct SemanticUsage {
1085    pub import_binding_usage: ImportBindingUsage,
1086    pub auto_import_candidates: Vec<String>,
1087}
1088
1089pub fn compute_semantic_usage(
1090    program: &Program<'_>,
1091    imports: &[ImportInfo],
1092    template_used: &rustc_hash::FxHashSet<String>,
1093) -> SemanticUsage {
1094    use oxc_semantic::SemanticBuilder;
1095    use rustc_hash::FxHashSet;
1096
1097    let semantic_ret = SemanticBuilder::new().build(program);
1098    let semantic = semantic_ret.semantic;
1099    let scoping = semantic.scoping();
1100    let root_scope = scoping.root_scope_id();
1101
1102    let mut unused = Vec::new();
1103    let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1104    let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
1105    for import in imports {
1106        if import.local_name.is_empty() {
1107            continue;
1108        }
1109        let name = oxc_str::Ident::from(import.local_name.as_str());
1110        if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
1111            let mut has_references = false;
1112            let mut has_type_references = false;
1113            let mut has_value_references = false;
1114
1115            for reference in scoping.get_resolved_references(symbol_id) {
1116                has_references = true;
1117                has_type_references |= reference.is_type();
1118                has_value_references |= reference.is_value();
1119            }
1120
1121            if !has_references {
1122                if !template_used.contains(&import.local_name) {
1123                    unused.push(import.local_name.clone());
1124                }
1125                continue;
1126            }
1127
1128            if has_type_references {
1129                type_referenced_bindings.insert(import.local_name.clone());
1130            }
1131            if has_value_references {
1132                value_referenced_bindings.insert(import.local_name.clone());
1133            }
1134        }
1135    }
1136
1137    unused.sort_unstable();
1138
1139    let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
1140    type_referenced_bindings.sort_unstable();
1141
1142    let mut value_referenced_bindings: Vec<String> =
1143        value_referenced_bindings.into_iter().collect();
1144    value_referenced_bindings.sort_unstable();
1145
1146    SemanticUsage {
1147        import_binding_usage: ImportBindingUsage {
1148            unused,
1149            type_referenced: type_referenced_bindings,
1150            value_referenced: value_referenced_bindings,
1151        },
1152        auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
1153    }
1154}
1155
1156pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
1157    use oxc_semantic::SemanticBuilder;
1158
1159    let semantic_ret = SemanticBuilder::new().build(program);
1160    let semantic = semantic_ret.semantic;
1161    compute_auto_import_candidates_from_semantic(semantic.scoping())
1162}
1163
1164fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
1165    use rustc_hash::FxHashSet;
1166
1167    let mut candidates: FxHashSet<String> = FxHashSet::default();
1168    for (name, reference_ids) in scoping.root_unresolved_references() {
1169        if reference_ids
1170            .iter()
1171            .any(|reference_id| scoping.get_reference(*reference_id).is_value())
1172        {
1173            candidates.insert(name.as_str().to_string());
1174        }
1175    }
1176
1177    let mut candidates: Vec<String> = candidates.into_iter().collect();
1178    candidates.sort_unstable();
1179    candidates
1180}
1181
1182/// Use `oxc_semantic` to summarize how import bindings are referenced in the file.
1183///
1184/// An import like `import { foo } from './utils'` where `foo` is never used
1185/// anywhere in the file should not count as a reference to the `foo` export.
1186/// This improves unused-export detection precision.
1187///
1188/// `template_used` lets framework template scanners (Glimmer `<template>`
1189/// blocks today; Vue/Svelte SFCs will follow) credit imports referenced only
1190/// in markup that `oxc_semantic` cannot see. Names in the set are filtered
1191/// out of the `unused` result before it is built. Pass `&FxHashSet::default()`
1192/// when no template scan applies.
1193///
1194/// Note: `get_resolved_references` counts both value-context and type-context
1195/// references. A value import used only as a type annotation (`const x: Foo`)
1196/// will have a type-position reference and will NOT appear in the unused list.
1197/// This is correct: `import { Foo }` (without `type`) may be needed at runtime.
1198pub fn compute_import_binding_usage(
1199    program: &Program<'_>,
1200    imports: &[ImportInfo],
1201    template_used: &rustc_hash::FxHashSet<String>,
1202) -> ImportBindingUsage {
1203    compute_semantic_usage(program, imports, template_used).import_binding_usage
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::{
1209        advance_jsdoc_brace_stack, has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag,
1210        parse_source_to_module, scan_jsdoc_imports_in,
1211    };
1212    use fallow_types::discover::FileId;
1213    use fallow_types::extract::{ImportInfo, ImportedName};
1214    use std::path::Path;
1215
1216    #[test]
1217    fn has_public_tag_matches_bare_tag() {
1218        assert!(has_public_tag(" * @public"));
1219    }
1220
1221    #[test]
1222    fn has_public_tag_matches_api_public_variant() {
1223        assert!(has_public_tag(" * @api public"));
1224    }
1225
1226    #[test]
1227    fn has_public_tag_rejects_partial_word() {
1228        assert!(!has_public_tag(" * @publicly"));
1229    }
1230
1231    #[test]
1232    fn has_public_tag_rejects_at_apipublic() {
1233        assert!(!has_public_tag(" * @apipublic"));
1234    }
1235
1236    #[test]
1237    fn has_public_tag_rejects_missing_at() {
1238        assert!(!has_public_tag(" * public"));
1239    }
1240
1241    #[test]
1242    fn has_internal_tag_matches_bare_tag() {
1243        assert!(has_internal_tag(" * @internal"));
1244    }
1245
1246    #[test]
1247    fn has_internal_tag_rejects_partial_word() {
1248        assert!(!has_internal_tag(" * @internalizer"));
1249    }
1250
1251    #[test]
1252    fn has_internal_tag_rejects_missing_at() {
1253        assert!(!has_internal_tag(" * internal"));
1254    }
1255
1256    #[test]
1257    fn has_beta_tag_matches_bare_tag() {
1258        assert!(has_beta_tag(" * @beta"));
1259    }
1260
1261    #[test]
1262    fn has_beta_tag_rejects_partial_word() {
1263        assert!(!has_beta_tag(" * @betaware"));
1264    }
1265
1266    #[test]
1267    fn has_beta_tag_rejects_missing_at() {
1268        assert!(!has_beta_tag(" * beta"));
1269    }
1270
1271    #[test]
1272    fn alpha_tag_standalone() {
1273        assert!(has_alpha_tag("@alpha"));
1274    }
1275
1276    #[test]
1277    fn alpha_tag_with_text() {
1278        assert!(has_alpha_tag("@alpha Some description"));
1279    }
1280
1281    #[test]
1282    fn alpha_tag_not_prefix() {
1283        assert!(!has_alpha_tag("@alphabet"));
1284    }
1285
1286    #[test]
1287    fn has_alpha_tag_rejects_missing_at() {
1288        assert!(!has_alpha_tag(" * alpha"));
1289    }
1290
1291    fn scan(body: &str) -> Vec<ImportInfo> {
1292        let mut imports = Vec::new();
1293        scan_jsdoc_imports_in(body, &mut imports);
1294        imports
1295    }
1296
1297    #[test]
1298    fn scan_jsdoc_single_import_with_member() {
1299        let imports = scan(" * @param foo {import('./types').Foo}");
1300        assert_eq!(imports.len(), 1);
1301        assert_eq!(imports[0].source, "./types");
1302        assert_eq!(
1303            imports[0].imported_name,
1304            ImportedName::Named("Foo".to_string())
1305        );
1306        assert!(imports[0].is_type_only);
1307        assert!(imports[0].local_name.is_empty());
1308    }
1309
1310    #[test]
1311    fn script_auto_import_candidates_capture_zero_import_value_refs() {
1312        let info = parse_source_to_module(
1313            FileId(0),
1314            Path::new("pages/index.ts"),
1315            r"
1316                useCounter();
1317                const price = formatPrice(10);
1318                const localOnly = () => null;
1319                localOnly();
1320                type Local = UseTypeOnly;
1321            ",
1322            0,
1323            false,
1324        );
1325
1326        assert!(
1327            info.auto_import_candidates
1328                .contains(&"formatPrice".to_string())
1329        );
1330        assert!(
1331            info.auto_import_candidates
1332                .contains(&"useCounter".to_string())
1333        );
1334        assert!(
1335            !info
1336                .auto_import_candidates
1337                .contains(&"UseTypeOnly".to_string())
1338        );
1339        assert!(
1340            !info
1341                .auto_import_candidates
1342                .contains(&"localOnly".to_string())
1343        );
1344    }
1345
1346    #[test]
1347    fn script_auto_import_candidates_skip_explicit_imports() {
1348        let info = parse_source_to_module(
1349            FileId(0),
1350            Path::new("pages/index.ts"),
1351            "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
1352            0,
1353            false,
1354        );
1355
1356        assert!(
1357            !info
1358                .auto_import_candidates
1359                .contains(&"useCounter".to_string())
1360        );
1361        assert!(
1362            info.auto_import_candidates
1363                .contains(&"useOther".to_string())
1364        );
1365    }
1366
1367    #[test]
1368    fn scan_jsdoc_double_quoted_path() {
1369        let imports = scan(r#" * @type {import("./types").Foo}"#);
1370        assert_eq!(imports.len(), 1);
1371        assert_eq!(imports[0].source, "./types");
1372    }
1373
1374    #[test]
1375    fn scan_jsdoc_multiple_imports_in_same_body() {
1376        let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
1377        assert_eq!(imports.len(), 2);
1378        assert_eq!(imports[0].source, "./a");
1379        assert_eq!(imports[1].source, "./b");
1380    }
1381
1382    #[test]
1383    fn scan_jsdoc_union_annotation_captures_both_members() {
1384        let imports = scan(" * @type {import('./a').A | import('./b').B}");
1385        assert_eq!(imports.len(), 2);
1386        assert_eq!(
1387            imports[0].imported_name,
1388            ImportedName::Named("A".to_string())
1389        );
1390        assert_eq!(
1391            imports[1].imported_name,
1392            ImportedName::Named("B".to_string())
1393        );
1394    }
1395
1396    #[test]
1397    fn scan_jsdoc_nested_member_uses_first_segment() {
1398        let imports = scan(" * @type {import('./types').ns.Foo}");
1399        assert_eq!(imports.len(), 1);
1400        assert_eq!(
1401            imports[0].imported_name,
1402            ImportedName::Named("ns".to_string())
1403        );
1404    }
1405
1406    #[test]
1407    fn scan_jsdoc_parent_relative_path() {
1408        let imports = scan(" * @type {import('../lib/types.js').Foo}");
1409        assert_eq!(imports.len(), 1);
1410        assert_eq!(imports[0].source, "../lib/types.js");
1411    }
1412
1413    #[test]
1414    fn scan_jsdoc_bare_package_specifier() {
1415        let imports = scan(" * @type {import('@scope/pkg').Client}");
1416        assert_eq!(imports.len(), 1);
1417        assert_eq!(imports[0].source, "@scope/pkg");
1418        assert_eq!(
1419            imports[0].imported_name,
1420            ImportedName::Named("Client".to_string())
1421        );
1422    }
1423
1424    #[test]
1425    fn scan_jsdoc_without_member_is_side_effect() {
1426        let imports = scan(" * @type {import('./types')}");
1427        assert_eq!(imports.len(), 1);
1428        assert_eq!(imports[0].source, "./types");
1429        assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
1430        assert!(imports[0].is_type_only);
1431    }
1432
1433    #[test]
1434    fn scan_jsdoc_empty_path_is_skipped() {
1435        let imports = scan(" * @type {import('').Foo}");
1436        assert!(imports.is_empty());
1437    }
1438
1439    #[test]
1440    fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
1441        let imports = scan(" * @type {import('./truncated");
1442        assert!(imports.is_empty());
1443    }
1444
1445    #[test]
1446    fn scan_jsdoc_missing_closing_paren_is_skipped() {
1447        let imports = scan(" * @type {import('./types'.Foo}");
1448        assert!(imports.is_empty());
1449    }
1450
1451    #[test]
1452    fn scan_jsdoc_whitespace_between_paren_and_dot() {
1453        let imports = scan(" * @type {import('./types') .Foo}");
1454        assert_eq!(imports.len(), 1);
1455        assert_eq!(imports[0].source, "./types");
1456        assert_eq!(
1457            imports[0].imported_name,
1458            ImportedName::Named("Foo".to_string())
1459        );
1460    }
1461
1462    #[test]
1463    fn scan_jsdoc_whitespace_between_paren_and_quote() {
1464        let imports = scan(" * @type {import( './types').Foo}");
1465        assert_eq!(imports.len(), 1);
1466        assert_eq!(imports[0].source, "./types");
1467    }
1468
1469    #[test]
1470    fn scan_jsdoc_non_quote_after_paren_skipped() {
1471        let imports = scan(" * @type {import(foo).Bar}");
1472        assert!(imports.is_empty());
1473    }
1474
1475    #[test]
1476    fn scan_jsdoc_ignores_prose_with_import_word() {
1477        let imports = scan(" * This is an important note about imports.");
1478        assert!(imports.is_empty());
1479    }
1480
1481    #[test]
1482    fn scan_jsdoc_utf8_path_works() {
1483        let imports = scan(" * @type {import('./héllo').Foo}");
1484        assert_eq!(imports.len(), 1);
1485        assert_eq!(imports[0].source, "./héllo");
1486    }
1487
1488    #[test]
1489    fn scan_jsdoc_empty_body_is_empty() {
1490        assert!(scan("").is_empty());
1491    }
1492
1493    #[test]
1494    fn scan_jsdoc_no_import_in_body_is_empty() {
1495        assert!(scan(" * @param foo The foo parameter").is_empty());
1496    }
1497
1498    /// Regression: `import('...')` in JSDoc prose (outside any `{...}` brace
1499    /// group) is documentation/example syntax, not a type annotation. It must
1500    /// not be reported as a real import. Without this scoping check, files
1501    /// whose header doc documents which import forms they handle would surface
1502    /// false-positive unresolved-import findings.
1503    #[test]
1504    fn scan_jsdoc_prose_import_outside_braces_is_skipped() {
1505        // Mirrors the exact shape of an extractor's header doc that lists
1506        // import forms as bullet-point examples.
1507        let body = "\n * Handles:\n * - Dynamic imports (await import('./prose')) \n * - Barrel exports (export * from './prose')\n";
1508        let imports = scan(body);
1509        assert!(
1510            imports.is_empty(),
1511            "prose import() should not be matched; got: {:?}",
1512            imports
1513                .iter()
1514                .map(|i| i.source.as_str())
1515                .collect::<Vec<_>>()
1516        );
1517    }
1518
1519    #[test]
1520    fn scan_jsdoc_prose_import_inside_example_object_is_skipped() {
1521        let body = "\n * @example\n * const loaders = {\n *   admin: () => import('./prose')\n * }";
1522        let imports = scan(body);
1523        assert!(
1524            imports.is_empty(),
1525            "object-literal example import() should not be matched; got: {:?}",
1526            imports
1527                .iter()
1528                .map(|i| i.source.as_str())
1529                .collect::<Vec<_>>()
1530        );
1531    }
1532
1533    #[test]
1534    fn scan_jsdoc_prose_import_inside_inline_braces_is_skipped() {
1535        let imports = scan(" * Use {import('./prose')} as an example string.");
1536        assert!(imports.is_empty());
1537    }
1538
1539    #[test]
1540    fn scan_jsdoc_bare_example_brace_import_is_skipped() {
1541        let imports = scan("\n * @example\n * { import('./prose') }\n");
1542        assert!(imports.is_empty());
1543    }
1544
1545    /// A real `{@type ...}` annotation following a prose mention of `import()`
1546    /// must still be matched. The fix narrows scope without breaking the
1547    /// intended JSDoc type-annotation behavior.
1548    #[test]
1549    fn scan_jsdoc_braced_import_after_prose_is_still_matched() {
1550        let body = " * Note: dynamic imports like import('./prose') are not types.\n * @type {import('./real').Foo}";
1551        let imports = scan(body);
1552        assert_eq!(imports.len(), 1, "got: {imports:?}");
1553        assert_eq!(imports[0].source, "./real");
1554        assert_eq!(
1555            imports[0].imported_name,
1556            ImportedName::Named("Foo".to_string())
1557        );
1558    }
1559
1560    #[test]
1561    fn scan_jsdoc_multiline_braced_type_tag_is_still_matched() {
1562        let body = "\n * @returns {\n *   import('./real').Foo\n * }";
1563        let imports = scan(body);
1564        assert_eq!(imports.len(), 1, "got: {imports:?}");
1565        assert_eq!(imports[0].source, "./real");
1566        assert_eq!(
1567            imports[0].imported_name,
1568            ImportedName::Named("Foo".to_string())
1569        );
1570    }
1571
1572    #[test]
1573    fn scan_jsdoc_type_tag_before_brace_line_is_still_matched() {
1574        let body = "\n * @type\n * { import('./real').Foo }\n";
1575        let imports = scan(body);
1576        assert_eq!(imports.len(), 1, "got: {imports:?}");
1577        assert_eq!(imports[0].source, "./real");
1578        assert_eq!(
1579            imports[0].imported_name,
1580            ImportedName::Named("Foo".to_string())
1581        );
1582    }
1583
1584    #[test]
1585    fn scan_jsdoc_satisfies_type_tag_is_still_matched() {
1586        let imports = scan(" * @satisfies {import('./real').Foo}");
1587        assert_eq!(imports.len(), 1, "got: {imports:?}");
1588        assert_eq!(imports[0].source, "./real");
1589        assert_eq!(
1590            imports[0].imported_name,
1591            ImportedName::Named("Foo".to_string())
1592        );
1593    }
1594
1595    #[test]
1596    fn scan_jsdoc_template_constraint_type_tag_is_still_matched() {
1597        let imports = scan(" * @template {import('./real').Foo} T");
1598        assert_eq!(imports.len(), 1, "got: {imports:?}");
1599        assert_eq!(imports[0].source, "./real");
1600        assert_eq!(
1601            imports[0].imported_name,
1602            ImportedName::Named("Foo".to_string())
1603        );
1604    }
1605
1606    #[test]
1607    fn scan_jsdoc_enum_type_tag_is_still_matched() {
1608        let imports = scan(" * @enum {import('./real').Foo}");
1609        assert_eq!(imports.len(), 1, "got: {imports:?}");
1610        assert_eq!(imports[0].source, "./real");
1611        assert_eq!(
1612            imports[0].imported_name,
1613            ImportedName::Named("Foo".to_string())
1614        );
1615    }
1616
1617    #[test]
1618    fn scan_jsdoc_appends_to_existing_imports() {
1619        let mut imports = vec![ImportInfo {
1620            source: "existing".to_string(),
1621            imported_name: ImportedName::Default,
1622            local_name: "existing".to_string(),
1623            is_type_only: false,
1624            from_style: false,
1625            span: oxc_span::Span::default(),
1626            source_span: oxc_span::Span::default(),
1627        }];
1628        scan_jsdoc_imports_in(" * @type {import('./new').Foo}", &mut imports);
1629        assert_eq!(imports.len(), 2);
1630        assert_eq!(imports[0].source, "existing");
1631        assert_eq!(imports[1].source, "./new");
1632    }
1633
1634    #[test]
1635    fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1636        let imports = scan(" * @type {import('./t').Abc}");
1637        assert_eq!(imports.len(), 1);
1638        assert_eq!(
1639            imports[0].imported_name,
1640            ImportedName::Named("Abc".to_string())
1641        );
1642    }
1643
1644    #[test]
1645    fn scan_jsdoc_empty_member_name_is_skipped() {
1646        let imports = scan(" * @type {import('./x').}");
1647        assert!(imports.is_empty());
1648    }
1649
1650    #[test]
1651    fn scan_jsdoc_many_imports_incremental_brace_stack_is_identical() {
1652        // Regression for the issue #1843 follow-up: the enclosing-brace lookup
1653        // is maintained incrementally across the whole comment rather than
1654        // rescanning every prefix. A comment packed with many `import(...)` type
1655        // refs must still extract exactly one import per `{...}` type group, in
1656        // order, with the same paths and member names as before.
1657        use std::fmt::Write as _;
1658        let mut body = String::from("/**\n");
1659        for i in 0..200 {
1660            let _ = writeln!(body, " * @param a{i} {{import('./m{i}').T{i}}} description");
1661        }
1662        // A prose `import(` outside any type brace group and a nested brace
1663        // must not add spurious imports or shift the enclosing-brace tracking.
1664        body.push_str(" * @remarks import('./ignored') appears in prose here\n");
1665        body.push_str(" * @typedef {{ nested: { deep: import('./deep').D } }} Obj\n");
1666        body.push_str(" */\n");
1667
1668        let imports = scan(&body);
1669        assert_eq!(imports.len(), 201, "got: {imports:?}");
1670        for (i, import) in imports.iter().take(200).enumerate() {
1671            assert_eq!(import.source, format!("./m{i}"));
1672            assert_eq!(import.imported_name, ImportedName::Named(format!("T{i}")));
1673            assert!(import.is_type_only);
1674            assert!(import.local_name.is_empty());
1675        }
1676        // The nested-brace occurrence still resolves against its enclosing group.
1677        assert_eq!(imports[200].source, "./deep");
1678        assert_eq!(
1679            imports[200].imported_name,
1680            ImportedName::Named("D".to_string())
1681        );
1682    }
1683
1684    #[test]
1685    fn scan_jsdoc_brace_stack_matches_offset_zero_rescan() {
1686        // Cross-checks the incremental brace stack against an independent
1687        // offset-zero rescan over the full prefix, on inputs where the
1688        // `import(` cursor skips over intervening braces (issue #1843 follow-up).
1689        let cases = [
1690            " * @type {import('./a').A} and {plain} then {import('./b').B}",
1691            " * @remarks { import('./skip') } @param x {import('./c').C}",
1692            " * text } stray close { import('./d').D } trailing",
1693            " * @type {{ a: import('./e').E, b: { c: import('./f').F } }}",
1694        ];
1695        for body in cases {
1696            let bytes = body.as_bytes();
1697            let mut cursor = 0;
1698            while let Some(rel) = body[cursor..].find("import(") {
1699                let import_pos = cursor + rel;
1700                // Independent offset-zero rescan reproducing the old helper.
1701                let mut fresh = Vec::new();
1702                for (idx, &b) in bytes[..import_pos].iter().enumerate() {
1703                    match b {
1704                        b'{' => fresh.push(idx),
1705                        b'}' => {
1706                            fresh.pop();
1707                        }
1708                        _ => {}
1709                    }
1710                }
1711                let mut stack = Vec::new();
1712                let mut scanned = 0;
1713                advance_jsdoc_brace_stack(bytes, &mut stack, &mut scanned, import_pos);
1714                assert_eq!(
1715                    stack.last().copied(),
1716                    fresh.last().copied(),
1717                    "enclosing brace mismatch at {import_pos} in {body:?}"
1718                );
1719                cursor = import_pos + "import(".len();
1720            }
1721        }
1722    }
1723}