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