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::{ImportInfo, VisibilityTag};
21
22fn source_type_for_path(path: &Path) -> SourceType {
23    match path.extension().and_then(|ext| ext.to_str()) {
24        Some("gts") => SourceType::ts(),
25        Some("gjs") => SourceType::mjs(),
26        _ => SourceType::from_path(path).unwrap_or_default(),
27    }
28}
29
30/// Parse source text into a [`ModuleInfo`].
31///
32/// When `need_complexity` is false the per-function complexity visitor is
33/// skipped, saving one full AST walk per file.  The dead-code analysis
34/// pipeline never consumes complexity data, so callers that only need
35/// imports/exports should pass `false`.
36pub fn parse_source_to_module(
37    file_id: FileId,
38    path: &Path,
39    source: &str,
40    content_hash: u64,
41    need_complexity: bool,
42) -> ModuleInfo {
43    let mut module =
44        parse_source_to_module_inner(file_id, path, source, content_hash, need_complexity);
45    module.iconify_prefixes = crate::iconify::extract_iconify_prefixes(path, source);
46    module
47}
48
49fn parse_source_to_module_inner(
50    file_id: FileId,
51    path: &Path,
52    source: &str,
53    content_hash: u64,
54    need_complexity: bool,
55) -> ModuleInfo {
56    let source = crate::strip_bom(source);
57    if is_sfc_file(path) {
58        return parse_sfc_to_module(file_id, path, source, content_hash, need_complexity);
59    }
60    if is_astro_file(path) {
61        return parse_astro_to_module(file_id, source, content_hash);
62    }
63    if is_mdx_file(path) {
64        return parse_mdx_to_module(file_id, source, content_hash);
65    }
66    if is_css_file(path) {
67        return parse_css_to_module(file_id, path, source, content_hash);
68    }
69    if is_graphql_file(path) {
70        return parse_graphql_to_module(file_id, source, content_hash);
71    }
72    if is_html_file(path) {
73        return parse_html_to_module_with_complexity(
74            file_id,
75            source,
76            content_hash,
77            need_complexity,
78        );
79    }
80
81    let stripped_glimmer_source = is_glimmer_file(path)
82        .then(|| strip_glimmer_templates(source))
83        .flatten();
84    let parser_source = stripped_glimmer_source.as_deref().unwrap_or(source);
85    let source_type = source_type_for_path(path);
86    let allocator = Allocator::default();
87    let parser_return = Parser::new(&allocator, parser_source, source_type).parse();
88
89    let mut parsed_suppressions =
90        crate::suppress::parse_suppressions(&parser_return.program.comments, source);
91
92    let mut extractor = ModuleInfoExtractor::new();
93    extractor.visit_program(&parser_return.program);
94    extractor.resolve_pending_local_export_specifiers();
95
96    let mut template_used_imports =
97        collect_glimmer_template_into_extractor(&mut extractor, path, source);
98
99    let mut semantic_usage = compute_semantic_usage(
100        &parser_return.program,
101        &extractor.imports,
102        &template_used_imports,
103    );
104
105    let line_offsets = fallow_types::extract::compute_line_offsets(source);
106
107    let mut complexity = if need_complexity {
108        crate::complexity::compute_complexity(&parser_return.program, parser_source, &line_offsets)
109    } else {
110        Vec::new()
111    };
112    if need_complexity {
113        append_inline_template_complexity(
114            &mut complexity,
115            &extractor.inline_template_findings,
116            &line_offsets,
117        );
118    }
119
120    let mut flag_uses = crate::flags::extract_flags(
121        &parser_return.program,
122        &line_offsets,
123        &[],   // built-in patterns only at parse time
124        &[],   // built-in prefixes only at parse time
125        false, // config object heuristics off at parse time (opt-in via config)
126    );
127
128    let total_extracted =
129        extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
130    let mut used_retry = false;
131    if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
132        let jsx_type = if source_type.is_typescript() {
133            SourceType::tsx()
134        } else {
135            SourceType::jsx()
136        };
137        let allocator2 = Allocator::default();
138        let retry_return = Parser::new(&allocator2, parser_source, jsx_type).parse();
139        let mut retry_extractor = ModuleInfoExtractor::new();
140        retry_extractor.visit_program(&retry_return.program);
141        retry_extractor.resolve_pending_local_export_specifiers();
142        let retry_total = retry_extractor.exports.len()
143            + retry_extractor.imports.len()
144            + retry_extractor.re_exports.len();
145        if retry_total > total_extracted {
146            template_used_imports =
147                collect_glimmer_template_into_extractor(&mut retry_extractor, path, source);
148            semantic_usage = compute_semantic_usage(
149                &retry_return.program,
150                &retry_extractor.imports,
151                &template_used_imports,
152            );
153            if need_complexity {
154                complexity = crate::complexity::compute_complexity(
155                    &retry_return.program,
156                    parser_source,
157                    &line_offsets,
158                );
159                append_inline_template_complexity(
160                    &mut complexity,
161                    &retry_extractor.inline_template_findings,
162                    &line_offsets,
163                );
164            }
165            flag_uses =
166                crate::flags::extract_flags(&retry_return.program, &line_offsets, &[], &[], false);
167            parsed_suppressions =
168                crate::suppress::parse_suppressions(&retry_return.program.comments, source);
169            apply_jsdoc_visibility_tags(
170                &mut retry_extractor.exports,
171                &retry_return.program.comments,
172                source,
173            );
174            extract_jsdoc_import_types(
175                &mut retry_extractor.imports,
176                &retry_return.program.comments,
177                source,
178            );
179            extractor = retry_extractor;
180            used_retry = true;
181        }
182    }
183
184    if !used_retry {
185        apply_jsdoc_visibility_tags(
186            &mut extractor.exports,
187            &parser_return.program.comments,
188            source,
189        );
190        extract_jsdoc_import_types(
191            &mut extractor.imports,
192            &parser_return.program.comments,
193            source,
194        );
195    }
196
197    let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
198    info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
199    info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
200    info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
201    info.auto_import_candidates = semantic_usage.auto_import_candidates;
202    info.line_offsets = line_offsets;
203    info.complexity = complexity;
204    info.flag_uses = flag_uses;
205
206    info
207}
208
209/// Scan Glimmer `<template>...</template>` blocks in a `.gts` / `.gjs` file
210/// and fold the result directly into `extractor`. Returns the set of import
211/// local names that the template body credits, so
212/// `compute_import_binding_usage` can skip them when building the unused list.
213///
214/// Mirrors the Angular inline-template path in
215/// `visitor/visit_impl.rs::visit_class`, which pushes
216/// `collect_angular_template_refs(...)` results straight onto
217/// `self.member_accesses`. The Glimmer scan can't run inside the JS visitor
218/// because template bodies are blanked by `strip_glimmer_templates` before
219/// the JS parse. The un-stripped source is only available here in
220/// `parse.rs`, so this is the earliest point we can fold the result in.
221///
222/// `extractor.member_accesses` receives every emitted `MemberAccess`
223/// (including `this.<member>` chain hops that survive even when there are
224/// zero imports; class-member tracking still needs them). Bindings the
225/// template credits are returned, not pushed; the caller threads them into
226/// `compute_import_binding_usage`'s skip-set so the `unused` vector never
227/// names them in the first place. This replaces the previous
228/// `apply_glimmer_template_usage` post-construction `info` mutation and
229/// the `retain` it performed against `unused_import_bindings`.
230fn collect_glimmer_template_into_extractor(
231    extractor: &mut ModuleInfoExtractor,
232    path: &Path,
233    source: &str,
234) -> rustc_hash::FxHashSet<String> {
235    use rustc_hash::FxHashSet;
236
237    if !is_glimmer_file(path) {
238        return FxHashSet::default();
239    }
240    let template_ranges = crate::glimmer::find_template_ranges(source);
241    if template_ranges.is_empty() {
242        return FxHashSet::default();
243    }
244
245    let imported_bindings: FxHashSet<String> = extractor
246        .imports
247        .iter()
248        .filter(|import| !import.local_name.is_empty())
249        .map(|import| import.local_name.clone())
250        .collect();
251
252    let usage = crate::sfc_template::glimmer::collect_glimmer_template_usage(
253        source,
254        &template_ranges,
255        &imported_bindings,
256    );
257    extractor.member_accesses.extend(usage.member_accesses);
258    usage.used_bindings
259}
260
261/// Synthesise `<template>` complexity findings for inline `@Component({ template: \`...\` })`
262/// decorators captured by the visitor pass.
263///
264/// The template-complexity scanner returns line/col relative to the template
265/// body itself; we replace those with the host file's line/col for the
266/// matched `@Component`/`@Directive` decorator. Anchoring at the decorator
267/// (rather than the literal's opening backtick) gives a useful jump-to-source
268/// landing inside the decorator block and lets `// fallow-ignore-next-line
269/// complexity` comments placed directly above the decorator suppress the
270/// finding through the existing health-side check, with no extra plumbing.
271fn append_inline_template_complexity(
272    complexity: &mut Vec<fallow_types::extract::FunctionComplexity>,
273    findings: &[crate::visitor::InlineTemplateFinding],
274    line_offsets: &[u32],
275) {
276    for finding in findings {
277        let Some(mut fc) = crate::template_complexity::compute_angular_template_complexity(
278            &finding.template_source,
279        ) else {
280            continue;
281        };
282        let (line, col) =
283            fallow_types::extract::byte_offset_to_line_col(line_offsets, finding.decorator_start);
284        fc.line = line;
285        fc.col = col;
286        complexity.push(fc);
287    }
288}
289
290/// Apply JSDoc visibility tags (`@public`, `@internal`, `@alpha`, `@beta`) to exports by
291/// matching leading JSDoc comments.
292///
293/// `Comment.attached_to` points to the `export` keyword byte offset, while
294/// `ExportInfo.span` stores the identifier byte offset (e.g., `foo` in
295/// `export const foo`). This function bridges the gap: it collects visibility
296/// comment attachment offsets with their tag, then for each export finds the
297/// nearest preceding attachment point and validates it's part of the same
298/// export statement.
299fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
300    if exports.is_empty() || comments.is_empty() {
301        return;
302    }
303
304    let mut tag_offsets: Vec<(u32, VisibilityTag)> = Vec::new();
305    for comment in comments {
306        if comment.is_jsdoc() {
307            let content_span = comment.content_span();
308            let start = content_span.start as usize;
309            let end = (content_span.end as usize).min(source.len());
310            if start < end {
311                let text = &source[start..end];
312                let tag = if has_public_tag(text) {
313                    VisibilityTag::Public
314                } else if has_internal_tag(text) {
315                    VisibilityTag::Internal
316                } else if has_alpha_tag(text) {
317                    VisibilityTag::Alpha
318                } else if has_beta_tag(text) {
319                    VisibilityTag::Beta
320                } else if has_expected_unused_tag(text) {
321                    VisibilityTag::ExpectedUnused
322                } else {
323                    continue;
324                };
325                tag_offsets.push((comment.attached_to, tag));
326            }
327        }
328    }
329
330    if tag_offsets.is_empty() {
331        return;
332    }
333
334    tag_offsets.sort_unstable_by_key(|&(offset, _)| offset);
335
336    for export in exports.iter_mut() {
337        if export.span.start == 0 && export.span.end == 0 {
338            continue;
339        }
340
341        if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _)| o) {
342            export.visibility = tag_offsets[idx].1;
343            continue;
344        }
345
346        let idx = tag_offsets.partition_point(|&(o, _)| o <= export.span.start);
347        if idx > 0 {
348            let (offset, tag) = tag_offsets[idx - 1];
349            let offset = offset as usize;
350            let export_start = export.span.start as usize;
351            if offset < export_start && export_start <= source.len() {
352                let between = &source[offset..export_start];
353                if between.starts_with("export") && !between.contains(';') && !between.contains('}')
354                {
355                    export.visibility = tag;
356                }
357            }
358        }
359    }
360}
361
362/// Check if a JSDoc comment body contains an `@internal` tag.
363fn has_internal_tag(comment_text: &str) -> bool {
364    for (i, _) in comment_text.match_indices("@internal") {
365        let after = i + "@internal".len();
366        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
367            return true;
368        }
369    }
370    false
371}
372
373/// Check if a JSDoc comment body contains a `@beta` tag.
374fn has_beta_tag(comment_text: &str) -> bool {
375    for (i, _) in comment_text.match_indices("@beta") {
376        let after = i + "@beta".len();
377        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
378            return true;
379        }
380    }
381    false
382}
383
384/// Check if a JSDoc comment body contains an `@alpha` tag.
385fn has_alpha_tag(comment_text: &str) -> bool {
386    for (i, _) in comment_text.match_indices("@alpha") {
387        let after = i + "@alpha".len();
388        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
389            return true;
390        }
391    }
392    false
393}
394
395/// Check if a JSDoc comment body contains an `@expected-unused` tag.
396fn has_expected_unused_tag(comment_text: &str) -> bool {
397    for (i, _) in comment_text.match_indices("@expected-unused") {
398        let after = i + "@expected-unused".len();
399        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
400            return true;
401        }
402    }
403    false
404}
405
406/// Check if a byte is an identifier-continuation character (alphanumeric or `_`).
407const fn is_ident_char(b: u8) -> bool {
408    b.is_ascii_alphanumeric() || b == b'_'
409}
410
411/// Scan JSDoc comments for `import('./path').Member` type expressions and push
412/// them onto `imports` as type-only imports.
413///
414/// JSDoc supports referencing types from other modules via `import()` expressions
415/// embedded in tag annotations, e.g.:
416///
417/// ```js
418/// /**
419///  * @param foo {import('./types.js').Foo}
420///  * @returns {import('./types').Bar}
421///  */
422/// ```
423///
424/// Without this scanner, the referenced export (`Foo`, `Bar`) is flagged as
425/// unused because no ES `import` statement binds it. The synthesized
426/// `ImportInfo` has `is_type_only: true` and an empty `local_name` so it does
427/// not interfere with `compute_unused_import_bindings` (which skips imports
428/// with empty local names) and does not add a cyclic-dependency edge.
429///
430/// All JSDoc tag contexts (`@param`, `@returns`, `@type`, `@typedef`,
431/// `@callback`, etc.) use the same `{type}` annotation syntax, so scanning
432/// the full comment body covers every call site in a single pass.
433fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
434    if comments.is_empty() {
435        return;
436    }
437
438    for comment in comments {
439        if !comment.is_jsdoc() {
440            continue;
441        }
442        let content_span = comment.content_span();
443        let start = content_span.start as usize;
444        let end = (content_span.end as usize).min(source.len());
445        if start >= end {
446            continue;
447        }
448        scan_jsdoc_imports_in(&source[start..end], imports);
449    }
450}
451
452/// Parse a single JSDoc comment body for `import('...').Member` expressions.
453///
454/// Matches both single and double quoted path literals and extracts the first
455/// identifier segment after `)\.` as the imported member name. Nested member
456/// access (`import('./x').ns.Foo`) yields `ns` as the imported name, which is
457/// correct for fallow's syntactic analysis since the resolver still adds the
458/// edge to the target module.
459fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
460    let bytes = body.as_bytes();
461    let mut cursor = 0;
462    while let Some(rel) = body[cursor..].find("import(") {
463        let open = cursor + rel + "import(".len();
464        cursor = open;
465        if open >= bytes.len() {
466            break;
467        }
468        let mut i = open;
469        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
470            i += 1;
471        }
472        if i >= bytes.len() {
473            break;
474        }
475        let quote = bytes[i];
476        if quote != b'\'' && quote != b'"' {
477            continue;
478        }
479        let path_start = i + 1;
480        let Some(rel_close) = body[path_start..].find(quote as char) else {
481            break;
482        };
483        let path_end = path_start + rel_close;
484        let path = &body[path_start..path_end];
485        if path.is_empty() {
486            cursor = path_end + 1;
487            continue;
488        }
489        let mut j = path_end + 1;
490        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
491            j += 1;
492        }
493        if j >= bytes.len() || bytes[j] != b')' {
494            cursor = path_end + 1;
495            continue;
496        }
497        j += 1;
498        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
499            j += 1;
500        }
501        cursor = j;
502        if j >= bytes.len() || bytes[j] != b'.' {
503            imports.push(ImportInfo {
504                source: path.to_string(),
505                imported_name: fallow_types::extract::ImportedName::SideEffect,
506                local_name: String::new(),
507                is_type_only: true,
508                from_style: false,
509                span: oxc_span::Span::default(),
510                source_span: oxc_span::Span::default(),
511            });
512            continue;
513        }
514        j += 1;
515        let name_start = j;
516        while j < bytes.len() && is_ident_char(bytes[j]) {
517            j += 1;
518        }
519        if name_start == j {
520            continue;
521        }
522        let member = &body[name_start..j];
523        cursor = j;
524        imports.push(ImportInfo {
525            source: path.to_string(),
526            imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
527            local_name: String::new(),
528            is_type_only: true,
529            from_style: false,
530            span: oxc_span::Span::default(),
531            source_span: oxc_span::Span::default(),
532        });
533    }
534}
535
536/// Check if a JSDoc comment body contains a `@public` or `@api public` tag.
537fn has_public_tag(comment_text: &str) -> bool {
538    for (i, _) in comment_text.match_indices("@public") {
539        let after = i + "@public".len();
540        if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
541            return true;
542        }
543    }
544    for (i, _) in comment_text.match_indices("@api") {
545        let after = i + "@api".len();
546        if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
547            let rest = comment_text[after..].trim_start();
548            if rest.starts_with("public") {
549                let after_public = "public".len();
550                if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
551                    return true;
552                }
553            }
554        }
555    }
556    false
557}
558
559#[derive(Debug, Default, PartialEq, Eq)]
560pub struct ImportBindingUsage {
561    pub unused: Vec<String>,
562    pub type_referenced: Vec<String>,
563    pub value_referenced: Vec<String>,
564}
565
566#[derive(Debug, Default, PartialEq, Eq)]
567pub struct SemanticUsage {
568    pub import_binding_usage: ImportBindingUsage,
569    pub auto_import_candidates: Vec<String>,
570}
571
572pub fn compute_semantic_usage(
573    program: &Program<'_>,
574    imports: &[ImportInfo],
575    template_used: &rustc_hash::FxHashSet<String>,
576) -> SemanticUsage {
577    use oxc_semantic::SemanticBuilder;
578    use rustc_hash::FxHashSet;
579
580    let semantic_ret = SemanticBuilder::new().build(program);
581    let semantic = semantic_ret.semantic;
582    let scoping = semantic.scoping();
583    let root_scope = scoping.root_scope_id();
584
585    let mut unused = Vec::new();
586    let mut type_referenced_bindings: FxHashSet<String> = FxHashSet::default();
587    let mut value_referenced_bindings: FxHashSet<String> = FxHashSet::default();
588    for import in imports {
589        if import.local_name.is_empty() {
590            continue;
591        }
592        let name = oxc_str::Ident::from(import.local_name.as_str());
593        if let Some(symbol_id) = scoping.get_binding(root_scope, name) {
594            let mut has_references = false;
595            let mut has_type_references = false;
596            let mut has_value_references = false;
597
598            for reference in scoping.get_resolved_references(symbol_id) {
599                has_references = true;
600                has_type_references |= reference.is_type();
601                has_value_references |= reference.is_value();
602            }
603
604            if !has_references {
605                if !template_used.contains(&import.local_name) {
606                    unused.push(import.local_name.clone());
607                }
608                continue;
609            }
610
611            if has_type_references {
612                type_referenced_bindings.insert(import.local_name.clone());
613            }
614            if has_value_references {
615                value_referenced_bindings.insert(import.local_name.clone());
616            }
617        }
618    }
619
620    unused.sort_unstable();
621
622    let mut type_referenced_bindings: Vec<String> = type_referenced_bindings.into_iter().collect();
623    type_referenced_bindings.sort_unstable();
624
625    let mut value_referenced_bindings: Vec<String> =
626        value_referenced_bindings.into_iter().collect();
627    value_referenced_bindings.sort_unstable();
628
629    SemanticUsage {
630        import_binding_usage: ImportBindingUsage {
631            unused,
632            type_referenced: type_referenced_bindings,
633            value_referenced: value_referenced_bindings,
634        },
635        auto_import_candidates: compute_auto_import_candidates_from_semantic(scoping),
636    }
637}
638
639pub fn compute_auto_import_candidates(program: &Program<'_>) -> Vec<String> {
640    use oxc_semantic::SemanticBuilder;
641
642    let semantic_ret = SemanticBuilder::new().build(program);
643    let semantic = semantic_ret.semantic;
644    compute_auto_import_candidates_from_semantic(semantic.scoping())
645}
646
647fn compute_auto_import_candidates_from_semantic(scoping: &oxc_semantic::Scoping) -> Vec<String> {
648    use rustc_hash::FxHashSet;
649
650    let mut candidates: FxHashSet<String> = FxHashSet::default();
651    for (name, reference_ids) in scoping.root_unresolved_references() {
652        if reference_ids
653            .iter()
654            .any(|reference_id| scoping.get_reference(*reference_id).is_value())
655        {
656            candidates.insert(name.as_str().to_string());
657        }
658    }
659
660    let mut candidates: Vec<String> = candidates.into_iter().collect();
661    candidates.sort_unstable();
662    candidates
663}
664
665/// Use `oxc_semantic` to summarize how import bindings are referenced in the file.
666///
667/// An import like `import { foo } from './utils'` where `foo` is never used
668/// anywhere in the file should not count as a reference to the `foo` export.
669/// This improves unused-export detection precision.
670///
671/// `template_used` lets framework template scanners (Glimmer `<template>`
672/// blocks today; Vue/Svelte SFCs will follow) credit imports referenced only
673/// in markup that `oxc_semantic` cannot see. Names in the set are filtered
674/// out of the `unused` result before it is built. Pass `&FxHashSet::default()`
675/// when no template scan applies.
676///
677/// Note: `get_resolved_references` counts both value-context and type-context
678/// references. A value import used only as a type annotation (`const x: Foo`)
679/// will have a type-position reference and will NOT appear in the unused list.
680/// This is correct: `import { Foo }` (without `type`) may be needed at runtime.
681pub fn compute_import_binding_usage(
682    program: &Program<'_>,
683    imports: &[ImportInfo],
684    template_used: &rustc_hash::FxHashSet<String>,
685) -> ImportBindingUsage {
686    compute_semantic_usage(program, imports, template_used).import_binding_usage
687}
688
689#[cfg(test)]
690mod tests {
691    use super::{
692        has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, parse_source_to_module,
693        scan_jsdoc_imports_in,
694    };
695    use fallow_types::discover::FileId;
696    use fallow_types::extract::{ImportInfo, ImportedName};
697    use std::path::Path;
698
699    #[test]
700    fn has_public_tag_matches_bare_tag() {
701        assert!(has_public_tag(" * @public"));
702    }
703
704    #[test]
705    fn has_public_tag_matches_api_public_variant() {
706        assert!(has_public_tag(" * @api public"));
707    }
708
709    #[test]
710    fn has_public_tag_rejects_partial_word() {
711        assert!(!has_public_tag(" * @publicly"));
712    }
713
714    #[test]
715    fn has_public_tag_rejects_at_apipublic() {
716        assert!(!has_public_tag(" * @apipublic"));
717    }
718
719    #[test]
720    fn has_public_tag_rejects_missing_at() {
721        assert!(!has_public_tag(" * public"));
722    }
723
724    #[test]
725    fn has_internal_tag_matches_bare_tag() {
726        assert!(has_internal_tag(" * @internal"));
727    }
728
729    #[test]
730    fn has_internal_tag_rejects_partial_word() {
731        assert!(!has_internal_tag(" * @internalizer"));
732    }
733
734    #[test]
735    fn has_internal_tag_rejects_missing_at() {
736        assert!(!has_internal_tag(" * internal"));
737    }
738
739    #[test]
740    fn has_beta_tag_matches_bare_tag() {
741        assert!(has_beta_tag(" * @beta"));
742    }
743
744    #[test]
745    fn has_beta_tag_rejects_partial_word() {
746        assert!(!has_beta_tag(" * @betaware"));
747    }
748
749    #[test]
750    fn has_beta_tag_rejects_missing_at() {
751        assert!(!has_beta_tag(" * beta"));
752    }
753
754    #[test]
755    fn alpha_tag_standalone() {
756        assert!(has_alpha_tag("@alpha"));
757    }
758
759    #[test]
760    fn alpha_tag_with_text() {
761        assert!(has_alpha_tag("@alpha Some description"));
762    }
763
764    #[test]
765    fn alpha_tag_not_prefix() {
766        assert!(!has_alpha_tag("@alphabet"));
767    }
768
769    #[test]
770    fn has_alpha_tag_rejects_missing_at() {
771        assert!(!has_alpha_tag(" * alpha"));
772    }
773
774    fn scan(body: &str) -> Vec<ImportInfo> {
775        let mut imports = Vec::new();
776        scan_jsdoc_imports_in(body, &mut imports);
777        imports
778    }
779
780    #[test]
781    fn scan_jsdoc_single_import_with_member() {
782        let imports = scan(" * @param foo {import('./types').Foo}");
783        assert_eq!(imports.len(), 1);
784        assert_eq!(imports[0].source, "./types");
785        assert_eq!(
786            imports[0].imported_name,
787            ImportedName::Named("Foo".to_string())
788        );
789        assert!(imports[0].is_type_only);
790        assert!(imports[0].local_name.is_empty());
791    }
792
793    #[test]
794    fn script_auto_import_candidates_capture_zero_import_value_refs() {
795        let info = parse_source_to_module(
796            FileId(0),
797            Path::new("pages/index.ts"),
798            r"
799                useCounter();
800                const price = formatPrice(10);
801                const localOnly = () => null;
802                localOnly();
803                type Local = UseTypeOnly;
804            ",
805            0,
806            false,
807        );
808
809        assert!(
810            info.auto_import_candidates
811                .contains(&"formatPrice".to_string())
812        );
813        assert!(
814            info.auto_import_candidates
815                .contains(&"useCounter".to_string())
816        );
817        assert!(
818            !info
819                .auto_import_candidates
820                .contains(&"UseTypeOnly".to_string())
821        );
822        assert!(
823            !info
824                .auto_import_candidates
825                .contains(&"localOnly".to_string())
826        );
827    }
828
829    #[test]
830    fn script_auto_import_candidates_skip_explicit_imports() {
831        let info = parse_source_to_module(
832            FileId(0),
833            Path::new("pages/index.ts"),
834            "import { useCounter } from '../composables/useCounter';\nuseCounter();\nuseOther();\n",
835            0,
836            false,
837        );
838
839        assert!(
840            !info
841                .auto_import_candidates
842                .contains(&"useCounter".to_string())
843        );
844        assert!(
845            info.auto_import_candidates
846                .contains(&"useOther".to_string())
847        );
848    }
849
850    #[test]
851    fn scan_jsdoc_double_quoted_path() {
852        let imports = scan(r#" * @type {import("./types").Foo}"#);
853        assert_eq!(imports.len(), 1);
854        assert_eq!(imports[0].source, "./types");
855    }
856
857    #[test]
858    fn scan_jsdoc_multiple_imports_in_same_body() {
859        let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
860        assert_eq!(imports.len(), 2);
861        assert_eq!(imports[0].source, "./a");
862        assert_eq!(imports[1].source, "./b");
863    }
864
865    #[test]
866    fn scan_jsdoc_union_annotation_captures_both_members() {
867        let imports = scan(" * @type {import('./a').A | import('./b').B}");
868        assert_eq!(imports.len(), 2);
869        assert_eq!(
870            imports[0].imported_name,
871            ImportedName::Named("A".to_string())
872        );
873        assert_eq!(
874            imports[1].imported_name,
875            ImportedName::Named("B".to_string())
876        );
877    }
878
879    #[test]
880    fn scan_jsdoc_nested_member_uses_first_segment() {
881        let imports = scan(" * @type {import('./types').ns.Foo}");
882        assert_eq!(imports.len(), 1);
883        assert_eq!(
884            imports[0].imported_name,
885            ImportedName::Named("ns".to_string())
886        );
887    }
888
889    #[test]
890    fn scan_jsdoc_parent_relative_path() {
891        let imports = scan(" * @type {import('../lib/types.js').Foo}");
892        assert_eq!(imports.len(), 1);
893        assert_eq!(imports[0].source, "../lib/types.js");
894    }
895
896    #[test]
897    fn scan_jsdoc_bare_package_specifier() {
898        let imports = scan(" * @type {import('@scope/pkg').Client}");
899        assert_eq!(imports.len(), 1);
900        assert_eq!(imports[0].source, "@scope/pkg");
901        assert_eq!(
902            imports[0].imported_name,
903            ImportedName::Named("Client".to_string())
904        );
905    }
906
907    #[test]
908    fn scan_jsdoc_without_member_is_side_effect() {
909        let imports = scan(" * @type {import('./types')}");
910        assert_eq!(imports.len(), 1);
911        assert_eq!(imports[0].source, "./types");
912        assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
913        assert!(imports[0].is_type_only);
914    }
915
916    #[test]
917    fn scan_jsdoc_empty_path_is_skipped() {
918        let imports = scan(" * @type {import('').Foo}");
919        assert!(imports.is_empty());
920    }
921
922    #[test]
923    fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
924        let imports = scan(" * @type {import('./truncated");
925        assert!(imports.is_empty());
926    }
927
928    #[test]
929    fn scan_jsdoc_missing_closing_paren_is_skipped() {
930        let imports = scan(" * @type {import('./types'.Foo}");
931        assert!(imports.is_empty());
932    }
933
934    #[test]
935    fn scan_jsdoc_whitespace_between_paren_and_dot() {
936        let imports = scan(" * @type {import('./types') .Foo}");
937        assert_eq!(imports.len(), 1);
938        assert_eq!(imports[0].source, "./types");
939        assert_eq!(
940            imports[0].imported_name,
941            ImportedName::Named("Foo".to_string())
942        );
943    }
944
945    #[test]
946    fn scan_jsdoc_whitespace_between_paren_and_quote() {
947        let imports = scan(" * @type {import( './types').Foo}");
948        assert_eq!(imports.len(), 1);
949        assert_eq!(imports[0].source, "./types");
950    }
951
952    #[test]
953    fn scan_jsdoc_non_quote_after_paren_skipped() {
954        let imports = scan(" * @type {import(foo).Bar}");
955        assert!(imports.is_empty());
956    }
957
958    #[test]
959    fn scan_jsdoc_ignores_prose_with_import_word() {
960        let imports = scan(" * This is an important note about imports.");
961        assert!(imports.is_empty());
962    }
963
964    #[test]
965    fn scan_jsdoc_utf8_path_works() {
966        let imports = scan(" * @type {import('./héllo').Foo}");
967        assert_eq!(imports.len(), 1);
968        assert_eq!(imports[0].source, "./héllo");
969    }
970
971    #[test]
972    fn scan_jsdoc_empty_body_is_empty() {
973        assert!(scan("").is_empty());
974    }
975
976    #[test]
977    fn scan_jsdoc_no_import_in_body_is_empty() {
978        assert!(scan(" * @param foo The foo parameter").is_empty());
979    }
980
981    #[test]
982    fn scan_jsdoc_appends_to_existing_imports() {
983        let mut imports = vec![ImportInfo {
984            source: "existing".to_string(),
985            imported_name: ImportedName::Default,
986            local_name: "existing".to_string(),
987            is_type_only: false,
988            from_style: false,
989            span: oxc_span::Span::default(),
990            source_span: oxc_span::Span::default(),
991        }];
992        scan_jsdoc_imports_in(" * {import('./new').Foo}", &mut imports);
993        assert_eq!(imports.len(), 2);
994        assert_eq!(imports[0].source, "existing");
995        assert_eq!(imports[1].source, "./new");
996    }
997
998    #[test]
999    fn scan_jsdoc_ident_boundary_stops_at_bracket() {
1000        let imports = scan(" * @type {import('./t').Abc}");
1001        assert_eq!(imports.len(), 1);
1002        assert_eq!(
1003            imports[0].imported_name,
1004            ImportedName::Named("Abc".to_string())
1005        );
1006    }
1007
1008    #[test]
1009    fn scan_jsdoc_empty_member_name_is_skipped() {
1010        let imports = scan(" * @type {import('./x').}");
1011        assert!(imports.is_empty());
1012    }
1013}