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