Skip to main content

fallow_core/
extract.rs

1use std::path::Path;
2use std::sync::LazyLock;
3
4use fallow_config::ResolvedConfig;
5use oxc_allocator::Allocator;
6use oxc_ast::ast::*;
7use oxc_ast_visit::Visit;
8use oxc_ast_visit::walk;
9use oxc_parser::Parser;
10use oxc_span::{SourceType, Span};
11use rayon::prelude::*;
12
13use crate::cache::CacheStore;
14use crate::discover::{DiscoveredFile, FileId};
15use crate::suppress::Suppression;
16
17/// Extracted module information from a single file.
18#[derive(Debug, Clone)]
19pub struct ModuleInfo {
20    pub file_id: FileId,
21    pub exports: Vec<ExportInfo>,
22    pub imports: Vec<ImportInfo>,
23    pub re_exports: Vec<ReExportInfo>,
24    pub dynamic_imports: Vec<DynamicImportInfo>,
25    pub dynamic_import_patterns: Vec<DynamicImportPattern>,
26    pub require_calls: Vec<RequireCallInfo>,
27    pub member_accesses: Vec<MemberAccess>,
28    /// Identifiers used in "all members consumed" patterns
29    /// (Object.values, Object.keys, Object.entries, for..in, spread, computed dynamic access).
30    pub whole_object_uses: Vec<String>,
31    pub has_cjs_exports: bool,
32    pub content_hash: u64,
33    /// Inline suppression directives parsed from comments.
34    pub suppressions: Vec<Suppression>,
35}
36
37/// A dynamic import with a pattern that can be partially resolved (e.g., template literals).
38#[derive(Debug, Clone)]
39pub struct DynamicImportPattern {
40    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
41    pub prefix: String,
42    /// Static suffix of the import path (e.g., ".json"), if any.
43    pub suffix: Option<String>,
44    pub span: Span,
45}
46
47/// An export declaration.
48#[derive(Debug, Clone, serde::Serialize)]
49pub struct ExportInfo {
50    pub name: ExportName,
51    pub local_name: Option<String>,
52    pub is_type_only: bool,
53    #[serde(serialize_with = "serialize_span")]
54    pub span: Span,
55    /// Members of this export (for enums and classes).
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub members: Vec<MemberInfo>,
58}
59
60/// A member of an enum or class.
61#[derive(Debug, Clone, serde::Serialize)]
62pub struct MemberInfo {
63    pub name: String,
64    pub kind: MemberKind,
65    #[serde(serialize_with = "serialize_span")]
66    pub span: Span,
67}
68
69/// The kind of member.
70#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MemberKind {
73    EnumMember,
74    ClassMethod,
75    ClassProperty,
76}
77
78/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bincode::Encode, bincode::Decode)]
80pub struct MemberAccess {
81    /// The identifier being accessed (the import name).
82    pub object: String,
83    /// The member being accessed.
84    pub member: String,
85}
86
87fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
88    use serde::ser::SerializeMap;
89    let mut map = serializer.serialize_map(Some(2))?;
90    map.serialize_entry("start", &span.start)?;
91    map.serialize_entry("end", &span.end)?;
92    map.end()
93}
94
95/// Export identifier.
96#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
97pub enum ExportName {
98    Named(String),
99    Default,
100}
101
102impl std::fmt::Display for ExportName {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Self::Named(n) => write!(f, "{n}"),
106            Self::Default => write!(f, "default"),
107        }
108    }
109}
110
111/// An import declaration.
112#[derive(Debug, Clone)]
113pub struct ImportInfo {
114    pub source: String,
115    pub imported_name: ImportedName,
116    pub local_name: String,
117    pub is_type_only: bool,
118    pub span: Span,
119}
120
121/// How a symbol is imported.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ImportedName {
124    Named(String),
125    Default,
126    Namespace,
127    SideEffect,
128}
129
130/// A re-export declaration.
131#[derive(Debug, Clone)]
132pub struct ReExportInfo {
133    pub source: String,
134    pub imported_name: String,
135    pub exported_name: String,
136    pub is_type_only: bool,
137}
138
139/// A dynamic `import()` call.
140#[derive(Debug, Clone)]
141pub struct DynamicImportInfo {
142    pub source: String,
143    pub span: Span,
144    /// Names destructured from the dynamic import result.
145    /// Non-empty means `const { a, b } = await import(...)` → Named imports.
146    /// Empty means simple `import(...)` or `const x = await import(...)` → Namespace.
147    pub destructured_names: Vec<String>,
148    /// The local variable name for `const x = await import(...)`.
149    /// Used for namespace import narrowing via member access tracking.
150    pub local_name: Option<String>,
151}
152
153/// A `require()` call.
154#[derive(Debug, Clone)]
155pub struct RequireCallInfo {
156    pub source: String,
157    pub span: Span,
158    /// Names destructured from the require() result.
159    /// Non-empty means `const { a, b } = require(...)` → Named imports.
160    /// Empty means simple `require(...)` or `const x = require(...)` → Namespace.
161    pub destructured_names: Vec<String>,
162    /// The local variable name for `const x = require(...)`.
163    /// Used for namespace import narrowing via member access tracking.
164    pub local_name: Option<String>,
165}
166
167/// Parse all files in parallel, extracting imports and exports.
168/// Uses the cache to skip reparsing files whose content hasn't changed.
169pub fn parse_all_files(
170    files: &[DiscoveredFile],
171    _config: &ResolvedConfig,
172    cache: Option<&CacheStore>,
173) -> Vec<ModuleInfo> {
174    use std::sync::atomic::{AtomicUsize, Ordering};
175    let cache_hits = AtomicUsize::new(0);
176    let cache_misses = AtomicUsize::new(0);
177
178    let result: Vec<ModuleInfo> = files
179        .par_iter()
180        .filter_map(|file| parse_single_file_cached(file, cache, &cache_hits, &cache_misses))
181        .collect();
182
183    let hits = cache_hits.load(Ordering::Relaxed);
184    let misses = cache_misses.load(Ordering::Relaxed);
185    if hits > 0 || misses > 0 {
186        tracing::info!(
187            cache_hits = hits,
188            cache_misses = misses,
189            "incremental cache stats"
190        );
191    }
192
193    result
194}
195
196/// Parse a single file, consulting the cache first.
197fn parse_single_file_cached(
198    file: &DiscoveredFile,
199    cache: Option<&CacheStore>,
200    cache_hits: &std::sync::atomic::AtomicUsize,
201    cache_misses: &std::sync::atomic::AtomicUsize,
202) -> Option<ModuleInfo> {
203    use std::sync::atomic::Ordering;
204
205    let source = std::fs::read_to_string(&file.path).ok()?;
206    let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
207
208    // Check cache before parsing
209    if let Some(store) = cache
210        && let Some(cached) = store.get(&file.path, content_hash)
211    {
212        cache_hits.fetch_add(1, Ordering::Relaxed);
213        return Some(crate::cache::cached_to_module(cached, file.id));
214    }
215    cache_misses.fetch_add(1, Ordering::Relaxed);
216
217    // Cache miss — do a full parse
218    Some(parse_source_to_module(
219        file.id,
220        &file.path,
221        &source,
222        content_hash,
223    ))
224}
225
226/// Parse a single file and extract module information.
227pub fn parse_single_file(file: &DiscoveredFile) -> Option<ModuleInfo> {
228    let source = std::fs::read_to_string(&file.path).ok()?;
229    let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
230    Some(parse_source_to_module(
231        file.id,
232        &file.path,
233        &source,
234        content_hash,
235    ))
236}
237
238/// Regex to extract `<script>` block content from Vue/Svelte SFCs.
239static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
240    regex::Regex::new(r#"(?is)<script\b(?P<attrs>[^>]*)>(?P<body>[\s\S]*?)</script>"#)
241        .expect("valid regex")
242});
243
244/// Regex to extract the `lang` attribute value from a script tag.
245static LANG_ATTR_RE: LazyLock<regex::Regex> =
246    LazyLock::new(|| regex::Regex::new(r#"lang\s*=\s*["'](\w+)["']"#).expect("valid regex"));
247
248pub(crate) struct SfcScript {
249    pub body: String,
250    pub is_typescript: bool,
251    /// Byte offset of the script body within the full SFC source.
252    pub byte_offset: usize,
253}
254
255pub(crate) fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
256    SCRIPT_BLOCK_RE
257        .captures_iter(source)
258        .map(|cap| {
259            let attrs = cap.name("attrs").map(|m| m.as_str()).unwrap_or("");
260            let body_match = cap.name("body");
261            let byte_offset = body_match.map(|m| m.start()).unwrap_or(0);
262            let body = body_match.map(|m| m.as_str()).unwrap_or("").to_string();
263            let is_typescript = LANG_ATTR_RE
264                .captures(attrs)
265                .and_then(|c| c.get(1))
266                .map(|m| matches!(m.as_str(), "ts" | "tsx"))
267                .unwrap_or(false);
268            SfcScript {
269                body,
270                is_typescript,
271                byte_offset,
272            }
273        })
274        .collect()
275}
276
277pub(crate) fn is_sfc_file(path: &Path) -> bool {
278    path.extension()
279        .and_then(|e| e.to_str())
280        .is_some_and(|ext| ext == "vue" || ext == "svelte")
281}
282
283/// Parse an SFC file by extracting and combining all `<script>` blocks.
284fn parse_sfc_to_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
285    let scripts = extract_sfc_scripts(source);
286
287    // For SFC files, use string scanning for suppression comments since script block
288    // byte offsets don't correspond to the original file positions.
289    let suppressions = crate::suppress::parse_suppressions_from_source(source);
290
291    let mut combined = ModuleInfo {
292        file_id,
293        exports: Vec::new(),
294        imports: Vec::new(),
295        re_exports: Vec::new(),
296        dynamic_imports: Vec::new(),
297        dynamic_import_patterns: Vec::new(),
298        require_calls: Vec::new(),
299        member_accesses: Vec::new(),
300        whole_object_uses: Vec::new(),
301        has_cjs_exports: false,
302        content_hash,
303        suppressions,
304    };
305
306    for script in &scripts {
307        let source_type = if script.is_typescript {
308            SourceType::ts()
309        } else {
310            SourceType::mjs()
311        };
312        let allocator = Allocator::default();
313        let parser_return = Parser::new(&allocator, &script.body, source_type).parse();
314        let mut extractor = ModuleInfoExtractor::new();
315        extractor.visit_program(&parser_return.program);
316
317        combined.imports.extend(extractor.imports);
318        combined.exports.extend(extractor.exports);
319        combined.re_exports.extend(extractor.re_exports);
320        combined.dynamic_imports.extend(extractor.dynamic_imports);
321        combined
322            .dynamic_import_patterns
323            .extend(extractor.dynamic_import_patterns);
324        combined.require_calls.extend(extractor.require_calls);
325        combined.member_accesses.extend(extractor.member_accesses);
326        combined
327            .whole_object_uses
328            .extend(extractor.whole_object_uses);
329        combined.has_cjs_exports |= extractor.has_cjs_exports;
330    }
331
332    combined
333}
334
335/// Parse source text into a ModuleInfo.
336fn parse_source_to_module(
337    file_id: FileId,
338    path: &Path,
339    source: &str,
340    content_hash: u64,
341) -> ModuleInfo {
342    if is_sfc_file(path) {
343        return parse_sfc_to_module(file_id, source, content_hash);
344    }
345
346    let source_type = SourceType::from_path(path).unwrap_or_default();
347    let allocator = Allocator::default();
348    let parser_return = Parser::new(&allocator, source, source_type).parse();
349
350    // Parse suppression comments
351    let suppressions = crate::suppress::parse_suppressions(&parser_return.program.comments, source);
352
353    // Extract imports/exports even if there are parse errors
354    let mut extractor = ModuleInfoExtractor::new();
355    extractor.visit_program(&parser_return.program);
356
357    // If parsing produced very few results relative to source size (likely parse errors
358    // from Flow types or JSX in .js files), retry with JSX/TSX source type as a fallback.
359    let total_extracted =
360        extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
361    if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
362        let jsx_type = if source_type.is_typescript() {
363            SourceType::tsx()
364        } else {
365            SourceType::jsx()
366        };
367        let allocator2 = Allocator::default();
368        let retry_return = Parser::new(&allocator2, source, jsx_type).parse();
369        let mut retry_extractor = ModuleInfoExtractor::new();
370        retry_extractor.visit_program(&retry_return.program);
371        let retry_total = retry_extractor.exports.len()
372            + retry_extractor.imports.len()
373            + retry_extractor.re_exports.len();
374        if retry_total > total_extracted {
375            extractor = retry_extractor;
376        }
377    }
378
379    ModuleInfo {
380        file_id,
381        exports: extractor.exports,
382        imports: extractor.imports,
383        re_exports: extractor.re_exports,
384        dynamic_imports: extractor.dynamic_imports,
385        dynamic_import_patterns: extractor.dynamic_import_patterns,
386        require_calls: extractor.require_calls,
387        member_accesses: extractor.member_accesses,
388        whole_object_uses: extractor.whole_object_uses,
389        has_cjs_exports: extractor.has_cjs_exports,
390        content_hash,
391        suppressions,
392    }
393}
394
395/// Parse from in-memory content (for LSP).
396pub fn parse_from_content(file_id: FileId, path: &Path, content: &str) -> ModuleInfo {
397    let content_hash = xxhash_rust::xxh3::xxh3_64(content.as_bytes());
398    parse_source_to_module(file_id, path, content, content_hash)
399}
400
401/// Extract class members (methods and properties) from a class declaration.
402fn extract_class_members(class: &Class<'_>) -> Vec<MemberInfo> {
403    let mut members = Vec::new();
404    for element in &class.body.body {
405        match element {
406            ClassElement::MethodDefinition(method) => {
407                if let Some(name) = method.key.static_name() {
408                    let name_str = name.to_string();
409                    // Skip constructor, private, and protected methods
410                    if name_str != "constructor"
411                        && !matches!(
412                            method.accessibility,
413                            Some(oxc_ast::ast::TSAccessibility::Private)
414                                | Some(oxc_ast::ast::TSAccessibility::Protected)
415                        )
416                    {
417                        members.push(MemberInfo {
418                            name: name_str,
419                            kind: MemberKind::ClassMethod,
420                            span: method.span,
421                        });
422                    }
423                }
424            }
425            ClassElement::PropertyDefinition(prop) => {
426                if let Some(name) = prop.key.static_name()
427                    && !matches!(
428                        prop.accessibility,
429                        Some(oxc_ast::ast::TSAccessibility::Private)
430                            | Some(oxc_ast::ast::TSAccessibility::Protected)
431                    )
432                {
433                    members.push(MemberInfo {
434                        name: name.to_string(),
435                        kind: MemberKind::ClassProperty,
436                        span: prop.span,
437                    });
438                }
439            }
440            _ => {}
441        }
442    }
443    members
444}
445
446/// Check if an argument expression is `import.meta.url`.
447fn is_meta_url_arg(arg: &Argument<'_>) -> bool {
448    if let Argument::StaticMemberExpression(member) = arg
449        && member.property.name == "url"
450        && matches!(member.object, Expression::MetaProperty(_))
451    {
452        return true;
453    }
454    false
455}
456
457/// AST visitor that extracts all import/export information in a single pass.
458struct ModuleInfoExtractor {
459    exports: Vec<ExportInfo>,
460    imports: Vec<ImportInfo>,
461    re_exports: Vec<ReExportInfo>,
462    dynamic_imports: Vec<DynamicImportInfo>,
463    dynamic_import_patterns: Vec<DynamicImportPattern>,
464    require_calls: Vec<RequireCallInfo>,
465    member_accesses: Vec<MemberAccess>,
466    whole_object_uses: Vec<String>,
467    has_cjs_exports: bool,
468    /// Spans of require() calls already handled via destructured require detection.
469    handled_require_spans: Vec<Span>,
470    /// Spans of import() expressions already handled via variable declarator detection.
471    handled_import_spans: Vec<Span>,
472}
473
474impl ModuleInfoExtractor {
475    fn new() -> Self {
476        Self {
477            exports: Vec::new(),
478            imports: Vec::new(),
479            re_exports: Vec::new(),
480            dynamic_imports: Vec::new(),
481            dynamic_import_patterns: Vec::new(),
482            require_calls: Vec::new(),
483            member_accesses: Vec::new(),
484            whole_object_uses: Vec::new(),
485            has_cjs_exports: false,
486            handled_require_spans: Vec::new(),
487            handled_import_spans: Vec::new(),
488        }
489    }
490
491    fn extract_declaration_exports(&mut self, decl: &Declaration<'_>, is_type_only: bool) {
492        match decl {
493            Declaration::VariableDeclaration(var) => {
494                for declarator in &var.declarations {
495                    self.extract_binding_pattern_names(&declarator.id, is_type_only);
496                }
497            }
498            Declaration::FunctionDeclaration(func) => {
499                if let Some(id) = func.id.as_ref() {
500                    self.exports.push(ExportInfo {
501                        name: ExportName::Named(id.name.to_string()),
502                        local_name: Some(id.name.to_string()),
503                        is_type_only,
504                        span: id.span,
505                        members: vec![],
506                    });
507                }
508            }
509            Declaration::ClassDeclaration(class) => {
510                if let Some(id) = class.id.as_ref() {
511                    let members = extract_class_members(class);
512                    self.exports.push(ExportInfo {
513                        name: ExportName::Named(id.name.to_string()),
514                        local_name: Some(id.name.to_string()),
515                        is_type_only,
516                        span: id.span,
517                        members,
518                    });
519                }
520            }
521            Declaration::TSTypeAliasDeclaration(alias) => {
522                self.exports.push(ExportInfo {
523                    name: ExportName::Named(alias.id.name.to_string()),
524                    local_name: Some(alias.id.name.to_string()),
525                    is_type_only: true,
526                    span: alias.id.span,
527                    members: vec![],
528                });
529            }
530            Declaration::TSInterfaceDeclaration(iface) => {
531                self.exports.push(ExportInfo {
532                    name: ExportName::Named(iface.id.name.to_string()),
533                    local_name: Some(iface.id.name.to_string()),
534                    is_type_only: true,
535                    span: iface.id.span,
536                    members: vec![],
537                });
538            }
539            Declaration::TSEnumDeclaration(enumd) => {
540                let members: Vec<MemberInfo> = enumd
541                    .body
542                    .members
543                    .iter()
544                    .filter_map(|member| {
545                        let name = match &member.id {
546                            TSEnumMemberName::Identifier(id) => id.name.to_string(),
547                            TSEnumMemberName::String(s) | TSEnumMemberName::ComputedString(s) => {
548                                s.value.to_string()
549                            }
550                            TSEnumMemberName::ComputedTemplateString(_) => return None,
551                        };
552                        Some(MemberInfo {
553                            name,
554                            kind: MemberKind::EnumMember,
555                            span: member.span,
556                        })
557                    })
558                    .collect();
559                self.exports.push(ExportInfo {
560                    name: ExportName::Named(enumd.id.name.to_string()),
561                    local_name: Some(enumd.id.name.to_string()),
562                    is_type_only,
563                    span: enumd.id.span,
564                    members,
565                });
566            }
567            Declaration::TSModuleDeclaration(module) => match &module.id {
568                TSModuleDeclarationName::Identifier(id) => {
569                    self.exports.push(ExportInfo {
570                        name: ExportName::Named(id.name.to_string()),
571                        local_name: Some(id.name.to_string()),
572                        is_type_only: true,
573                        span: id.span,
574                        members: vec![],
575                    });
576                }
577                TSModuleDeclarationName::StringLiteral(lit) => {
578                    self.exports.push(ExportInfo {
579                        name: ExportName::Named(lit.value.to_string()),
580                        local_name: Some(lit.value.to_string()),
581                        is_type_only: true,
582                        span: lit.span,
583                        members: vec![],
584                    });
585                }
586            },
587            _ => {}
588        }
589    }
590
591    fn extract_binding_pattern_names(&mut self, pattern: &BindingPattern<'_>, is_type_only: bool) {
592        match pattern {
593            BindingPattern::BindingIdentifier(id) => {
594                self.exports.push(ExportInfo {
595                    name: ExportName::Named(id.name.to_string()),
596                    local_name: Some(id.name.to_string()),
597                    is_type_only,
598                    span: id.span,
599                    members: vec![],
600                });
601            }
602            BindingPattern::ObjectPattern(obj) => {
603                for prop in &obj.properties {
604                    self.extract_binding_pattern_names(&prop.value, is_type_only);
605                }
606            }
607            BindingPattern::ArrayPattern(arr) => {
608                for elem in arr.elements.iter().flatten() {
609                    self.extract_binding_pattern_names(elem, is_type_only);
610                }
611            }
612            BindingPattern::AssignmentPattern(assign) => {
613                self.extract_binding_pattern_names(&assign.left, is_type_only);
614            }
615        }
616    }
617}
618
619impl<'a> Visit<'a> for ModuleInfoExtractor {
620    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
621        let source = decl.source.value.to_string();
622        let is_type_only = decl.import_kind.is_type();
623
624        if let Some(specifiers) = &decl.specifiers {
625            for spec in specifiers {
626                match spec {
627                    ImportDeclarationSpecifier::ImportSpecifier(s) => {
628                        self.imports.push(ImportInfo {
629                            source: source.clone(),
630                            imported_name: ImportedName::Named(s.imported.name().to_string()),
631                            local_name: s.local.name.to_string(),
632                            is_type_only: is_type_only || s.import_kind.is_type(),
633                            span: s.span,
634                        });
635                    }
636                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
637                        self.imports.push(ImportInfo {
638                            source: source.clone(),
639                            imported_name: ImportedName::Default,
640                            local_name: s.local.name.to_string(),
641                            is_type_only,
642                            span: s.span,
643                        });
644                    }
645                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
646                        self.imports.push(ImportInfo {
647                            source: source.clone(),
648                            imported_name: ImportedName::Namespace,
649                            local_name: s.local.name.to_string(),
650                            is_type_only,
651                            span: s.span,
652                        });
653                    }
654                }
655            }
656        } else {
657            // Side-effect import: import './styles.css'
658            self.imports.push(ImportInfo {
659                source,
660                imported_name: ImportedName::SideEffect,
661                local_name: String::new(),
662                is_type_only: false,
663                span: decl.span,
664            });
665        }
666    }
667
668    fn visit_export_named_declaration(&mut self, decl: &ExportNamedDeclaration<'a>) {
669        let is_type_only = decl.export_kind.is_type();
670
671        if let Some(source) = &decl.source {
672            // Re-export: export { foo } from './bar'
673            for spec in &decl.specifiers {
674                self.re_exports.push(ReExportInfo {
675                    source: source.value.to_string(),
676                    imported_name: spec.local.name().to_string(),
677                    exported_name: spec.exported.name().to_string(),
678                    is_type_only: is_type_only || spec.export_kind.is_type(),
679                });
680            }
681        } else {
682            // Local export
683            if let Some(declaration) = &decl.declaration {
684                self.extract_declaration_exports(declaration, is_type_only);
685            }
686            for spec in &decl.specifiers {
687                self.exports.push(ExportInfo {
688                    name: ExportName::Named(spec.exported.name().to_string()),
689                    local_name: Some(spec.local.name().to_string()),
690                    is_type_only: is_type_only || spec.export_kind.is_type(),
691                    span: spec.span,
692                    members: vec![],
693                });
694            }
695        }
696
697        walk::walk_export_named_declaration(self, decl);
698    }
699
700    fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) {
701        self.exports.push(ExportInfo {
702            name: ExportName::Default,
703            local_name: None,
704            is_type_only: false,
705            span: decl.span,
706            members: vec![],
707        });
708
709        walk::walk_export_default_declaration(self, decl);
710    }
711
712    fn visit_export_all_declaration(&mut self, decl: &ExportAllDeclaration<'a>) {
713        let exported_name = decl
714            .exported
715            .as_ref()
716            .map(|e| e.name().to_string())
717            .unwrap_or_else(|| "*".to_string());
718
719        self.re_exports.push(ReExportInfo {
720            source: decl.source.value.to_string(),
721            imported_name: "*".to_string(),
722            exported_name,
723            is_type_only: decl.export_kind.is_type(),
724        });
725
726        walk::walk_export_all_declaration(self, decl);
727    }
728
729    fn visit_import_expression(&mut self, expr: &ImportExpression<'a>) {
730        // Skip imports already handled via visit_variable_declaration (with local_name capture)
731        if self.handled_import_spans.contains(&expr.span) {
732            walk::walk_import_expression(self, expr);
733            return;
734        }
735
736        match &expr.source {
737            Expression::StringLiteral(lit) => {
738                self.dynamic_imports.push(DynamicImportInfo {
739                    source: lit.value.to_string(),
740                    span: expr.span,
741                    destructured_names: Vec::new(),
742                    local_name: None,
743                });
744            }
745            Expression::TemplateLiteral(tpl)
746                if !tpl.quasis.is_empty() && !tpl.expressions.is_empty() =>
747            {
748                // Template literal with expressions: extract prefix/suffix.
749                // For multi-expression templates like `./a/${x}/${y}.js` (3 quasis),
750                // use `**/` in the prefix so the glob can match nested directories.
751                let first_quasi = tpl.quasis[0].value.raw.to_string();
752                if first_quasi.starts_with("./") || first_quasi.starts_with("../") {
753                    let prefix = if tpl.expressions.len() > 1 {
754                        // Multiple dynamic segments: use ** to match any nesting depth
755                        format!("{first_quasi}**/")
756                    } else {
757                        first_quasi
758                    };
759                    let suffix = if tpl.quasis.len() > 1 {
760                        let last = &tpl.quasis[tpl.quasis.len() - 1];
761                        let s = last.value.raw.to_string();
762                        if s.is_empty() { None } else { Some(s) }
763                    } else {
764                        None
765                    };
766                    self.dynamic_import_patterns.push(DynamicImportPattern {
767                        prefix,
768                        suffix,
769                        span: expr.span,
770                    });
771                }
772            }
773            Expression::TemplateLiteral(tpl)
774                if !tpl.quasis.is_empty() && tpl.expressions.is_empty() =>
775            {
776                // No-substitution template literal: treat as exact string
777                let value = tpl.quasis[0].value.raw.to_string();
778                if !value.is_empty() {
779                    self.dynamic_imports.push(DynamicImportInfo {
780                        source: value,
781                        span: expr.span,
782                        destructured_names: Vec::new(),
783                        local_name: None,
784                    });
785                }
786            }
787            Expression::BinaryExpression(bin)
788                if bin.operator == oxc_ast::ast::BinaryOperator::Addition =>
789            {
790                if let Some((prefix, suffix)) = extract_concat_parts(bin)
791                    && (prefix.starts_with("./") || prefix.starts_with("../"))
792                {
793                    self.dynamic_import_patterns.push(DynamicImportPattern {
794                        prefix,
795                        suffix,
796                        span: expr.span,
797                    });
798                }
799            }
800            _ => {}
801        }
802
803        walk::walk_import_expression(self, expr);
804    }
805
806    fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'a>) {
807        for declarator in &decl.declarations {
808            let Some(init) = &declarator.init else {
809                continue;
810            };
811
812            // Try to detect `const x = require('./y')` patterns
813            if let Expression::CallExpression(call) = init
814                && let Expression::Identifier(callee) = &call.callee
815                && callee.name == "require"
816                && let Some(Argument::StringLiteral(lit)) = call.arguments.first()
817            {
818                let source = lit.value.to_string();
819                match &declarator.id {
820                    BindingPattern::ObjectPattern(obj_pat) => {
821                        if obj_pat.rest.is_some() {
822                            self.require_calls.push(RequireCallInfo {
823                                source,
824                                span: call.span,
825                                destructured_names: Vec::new(),
826                                local_name: None,
827                            });
828                        } else {
829                            let names: Vec<String> = obj_pat
830                                .properties
831                                .iter()
832                                .filter_map(|prop| prop.key.static_name().map(|n| n.to_string()))
833                                .collect();
834                            self.require_calls.push(RequireCallInfo {
835                                source,
836                                span: call.span,
837                                destructured_names: names,
838                                local_name: None,
839                            });
840                        }
841                        self.handled_require_spans.push(call.span);
842                    }
843                    BindingPattern::BindingIdentifier(id) => {
844                        // `const mod = require('./x')` → Namespace with local_name for narrowing
845                        self.require_calls.push(RequireCallInfo {
846                            source,
847                            span: call.span,
848                            destructured_names: Vec::new(),
849                            local_name: Some(id.name.to_string()),
850                        });
851                        self.handled_require_spans.push(call.span);
852                    }
853                    _ => {}
854                }
855                continue;
856            }
857
858            // Try to detect `const x = await import('./y')` and `const x = import('./y')` patterns
859            // The import expression may be wrapped in an AwaitExpression or used directly.
860            let import_expr = match init {
861                Expression::AwaitExpression(await_expr) => {
862                    if let Expression::ImportExpression(imp) = &await_expr.argument {
863                        Some(imp)
864                    } else {
865                        None
866                    }
867                }
868                Expression::ImportExpression(imp) => Some(imp),
869                _ => None,
870            };
871
872            let Some(import_expr) = import_expr else {
873                continue;
874            };
875
876            let Expression::StringLiteral(lit) = &import_expr.source else {
877                continue;
878            };
879
880            let source = lit.value.to_string();
881
882            match &declarator.id {
883                BindingPattern::ObjectPattern(obj_pat) => {
884                    // `const { foo, bar } = await import('./x')` → Named imports
885                    if obj_pat.rest.is_some() {
886                        // Has rest element: conservative, treat as namespace
887                        self.dynamic_imports.push(DynamicImportInfo {
888                            source,
889                            span: import_expr.span,
890                            destructured_names: Vec::new(),
891                            local_name: None,
892                        });
893                    } else {
894                        let names: Vec<String> = obj_pat
895                            .properties
896                            .iter()
897                            .filter_map(|prop| prop.key.static_name().map(|n| n.to_string()))
898                            .collect();
899                        self.dynamic_imports.push(DynamicImportInfo {
900                            source,
901                            span: import_expr.span,
902                            destructured_names: names,
903                            local_name: None,
904                        });
905                    }
906                    self.handled_import_spans.push(import_expr.span);
907                }
908                BindingPattern::BindingIdentifier(id) => {
909                    // `const mod = await import('./x')` → Namespace with local_name for narrowing
910                    self.dynamic_imports.push(DynamicImportInfo {
911                        source,
912                        span: import_expr.span,
913                        destructured_names: Vec::new(),
914                        local_name: Some(id.name.to_string()),
915                    });
916                    self.handled_import_spans.push(import_expr.span);
917                }
918                _ => {}
919            }
920        }
921        walk::walk_variable_declaration(self, decl);
922    }
923
924    fn visit_call_expression(&mut self, expr: &CallExpression<'a>) {
925        // Detect require()
926        if let Expression::Identifier(ident) = &expr.callee
927            && ident.name == "require"
928            && let Some(Argument::StringLiteral(lit)) = expr.arguments.first()
929            && !self.handled_require_spans.contains(&expr.span)
930        {
931            self.require_calls.push(RequireCallInfo {
932                source: lit.value.to_string(),
933                span: expr.span,
934                destructured_names: Vec::new(),
935                local_name: None,
936            });
937        }
938
939        // Detect Object.values(X), Object.keys(X), Object.entries(X) — whole-object use
940        if let Expression::StaticMemberExpression(member) = &expr.callee
941            && let Expression::Identifier(obj) = &member.object
942            && obj.name == "Object"
943            && matches!(member.property.name.as_str(), "values" | "keys" | "entries")
944            && let Some(Argument::Identifier(arg_ident)) = expr.arguments.first()
945        {
946            self.whole_object_uses.push(arg_ident.name.to_string());
947        }
948
949        // Detect import.meta.glob() — Vite pattern
950        if let Expression::StaticMemberExpression(member) = &expr.callee
951            && member.property.name == "glob"
952            && matches!(member.object, Expression::MetaProperty(_))
953            && let Some(first_arg) = expr.arguments.first()
954        {
955            match first_arg {
956                Argument::StringLiteral(lit) => {
957                    let s = lit.value.to_string();
958                    if s.starts_with("./") || s.starts_with("../") {
959                        self.dynamic_import_patterns.push(DynamicImportPattern {
960                            prefix: s,
961                            suffix: None,
962                            span: expr.span,
963                        });
964                    }
965                }
966                Argument::ArrayExpression(arr) => {
967                    for elem in &arr.elements {
968                        if let ArrayExpressionElement::StringLiteral(lit) = elem {
969                            let s = lit.value.to_string();
970                            if s.starts_with("./") || s.starts_with("../") {
971                                self.dynamic_import_patterns.push(DynamicImportPattern {
972                                    prefix: s,
973                                    suffix: None,
974                                    span: expr.span,
975                                });
976                            }
977                        }
978                    }
979                }
980                _ => {}
981            }
982        }
983
984        // Detect require.context() — Webpack pattern
985        if let Expression::StaticMemberExpression(member) = &expr.callee
986            && member.property.name == "context"
987            && let Expression::Identifier(obj) = &member.object
988            && obj.name == "require"
989            && let Some(Argument::StringLiteral(dir_lit)) = expr.arguments.first()
990        {
991            let dir = dir_lit.value.to_string();
992            if dir.starts_with("./") || dir.starts_with("../") {
993                let recursive = expr
994                    .arguments
995                    .get(1)
996                    .is_some_and(|arg| matches!(arg, Argument::BooleanLiteral(b) if b.value));
997                let prefix = if recursive {
998                    format!("{dir}/**/")
999                } else {
1000                    format!("{dir}/")
1001                };
1002                self.dynamic_import_patterns.push(DynamicImportPattern {
1003                    prefix,
1004                    suffix: None,
1005                    span: expr.span,
1006                });
1007            }
1008        }
1009
1010        walk::walk_call_expression(self, expr);
1011    }
1012
1013    fn visit_new_expression(&mut self, expr: &oxc_ast::ast::NewExpression<'a>) {
1014        // Detect `new URL('./path', import.meta.url)` pattern.
1015        // This is the standard Vite/bundler pattern for referencing worker files and assets.
1016        // Treat the path as a dynamic import so the target file is considered reachable.
1017        if let Expression::Identifier(callee) = &expr.callee
1018            && callee.name == "URL"
1019            && expr.arguments.len() == 2
1020            && let Some(Argument::StringLiteral(path_lit)) = expr.arguments.first()
1021            && is_meta_url_arg(&expr.arguments[1])
1022            && (path_lit.value.starts_with("./") || path_lit.value.starts_with("../"))
1023        {
1024            self.dynamic_imports.push(DynamicImportInfo {
1025                source: path_lit.value.to_string(),
1026                span: expr.span,
1027                destructured_names: Vec::new(),
1028                local_name: None,
1029            });
1030        }
1031
1032        walk::walk_new_expression(self, expr);
1033    }
1034
1035    fn visit_assignment_expression(&mut self, expr: &AssignmentExpression<'a>) {
1036        // Detect module.exports = ... and exports.foo = ...
1037        if let AssignmentTarget::StaticMemberExpression(member) = &expr.left {
1038            if let Expression::Identifier(obj) = &member.object {
1039                if obj.name == "module" && member.property.name == "exports" {
1040                    self.has_cjs_exports = true;
1041                    // Extract exports from `module.exports = { foo, bar }`
1042                    if let Expression::ObjectExpression(obj_expr) = &expr.right {
1043                        for prop in &obj_expr.properties {
1044                            if let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(p) = prop
1045                                && let Some(name) = p.key.static_name()
1046                            {
1047                                self.exports.push(ExportInfo {
1048                                    name: ExportName::Named(name.to_string()),
1049                                    local_name: None,
1050                                    is_type_only: false,
1051                                    span: p.span,
1052                                    members: vec![],
1053                                });
1054                            }
1055                        }
1056                    }
1057                }
1058                if obj.name == "exports" {
1059                    self.has_cjs_exports = true;
1060                    self.exports.push(ExportInfo {
1061                        name: ExportName::Named(member.property.name.to_string()),
1062                        local_name: None,
1063                        is_type_only: false,
1064                        span: expr.span,
1065                        members: vec![],
1066                    });
1067                }
1068            }
1069            // Capture `this.member = ...` assignment patterns within class bodies.
1070            // This indicates the class uses the member internally.
1071            if matches!(member.object, Expression::ThisExpression(_)) {
1072                self.member_accesses.push(MemberAccess {
1073                    object: "this".to_string(),
1074                    member: member.property.name.to_string(),
1075                });
1076            }
1077        }
1078        walk::walk_assignment_expression(self, expr);
1079    }
1080
1081    fn visit_static_member_expression(&mut self, expr: &StaticMemberExpression<'a>) {
1082        // Capture `Identifier.member` patterns (e.g., `Status.Active`, `MyClass.create()`)
1083        if let Expression::Identifier(obj) = &expr.object {
1084            self.member_accesses.push(MemberAccess {
1085                object: obj.name.to_string(),
1086                member: expr.property.name.to_string(),
1087            });
1088        }
1089        // Capture `this.member` patterns within class bodies — these members are used internally
1090        if matches!(expr.object, Expression::ThisExpression(_)) {
1091            self.member_accesses.push(MemberAccess {
1092                object: "this".to_string(),
1093                member: expr.property.name.to_string(),
1094            });
1095        }
1096        walk::walk_static_member_expression(self, expr);
1097    }
1098
1099    fn visit_computed_member_expression(&mut self, expr: &ComputedMemberExpression<'a>) {
1100        if let Expression::Identifier(obj) = &expr.object {
1101            if let Expression::StringLiteral(lit) = &expr.expression {
1102                // Computed access with string literal resolves to a specific member
1103                self.member_accesses.push(MemberAccess {
1104                    object: obj.name.to_string(),
1105                    member: lit.value.to_string(),
1106                });
1107            } else {
1108                // Dynamic computed access — mark all members as used
1109                self.whole_object_uses.push(obj.name.to_string());
1110            }
1111        }
1112        walk::walk_computed_member_expression(self, expr);
1113    }
1114
1115    fn visit_for_in_statement(&mut self, stmt: &ForInStatement<'a>) {
1116        if let Expression::Identifier(ident) = &stmt.right {
1117            self.whole_object_uses.push(ident.name.to_string());
1118        }
1119        walk::walk_for_in_statement(self, stmt);
1120    }
1121
1122    fn visit_spread_element(&mut self, elem: &SpreadElement<'a>) {
1123        if let Expression::Identifier(ident) = &elem.argument {
1124            self.whole_object_uses.push(ident.name.to_string());
1125        }
1126        walk::walk_spread_element(self, elem);
1127    }
1128}
1129
1130/// Extract static prefix and optional suffix from a binary addition chain.
1131fn extract_concat_parts(expr: &BinaryExpression<'_>) -> Option<(String, Option<String>)> {
1132    let prefix = extract_leading_string(&expr.left)?;
1133    let suffix = extract_trailing_string(&expr.right);
1134    Some((prefix, suffix))
1135}
1136
1137fn extract_leading_string(expr: &Expression<'_>) -> Option<String> {
1138    match expr {
1139        Expression::StringLiteral(lit) => Some(lit.value.to_string()),
1140        Expression::BinaryExpression(bin)
1141            if bin.operator == oxc_ast::ast::BinaryOperator::Addition =>
1142        {
1143            extract_leading_string(&bin.left)
1144        }
1145        _ => None,
1146    }
1147}
1148
1149fn extract_trailing_string(expr: &Expression<'_>) -> Option<String> {
1150    match expr {
1151        Expression::StringLiteral(lit) => {
1152            let s = lit.value.to_string();
1153            if s.is_empty() { None } else { Some(s) }
1154        }
1155        _ => None,
1156    }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    fn parse_source(source: &str) -> ModuleInfo {
1164        parse_source_to_module(FileId(0), Path::new("test.ts"), source, 0)
1165    }
1166
1167    #[test]
1168    fn extracts_named_exports() {
1169        let info = parse_source("export const foo = 1; export function bar() {}");
1170        assert_eq!(info.exports.len(), 2);
1171        assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
1172        assert_eq!(info.exports[1].name, ExportName::Named("bar".to_string()));
1173    }
1174
1175    #[test]
1176    fn extracts_default_export() {
1177        let info = parse_source("export default function main() {}");
1178        assert_eq!(info.exports.len(), 1);
1179        assert_eq!(info.exports[0].name, ExportName::Default);
1180    }
1181
1182    #[test]
1183    fn extracts_named_imports() {
1184        let info = parse_source("import { foo, bar } from './utils';");
1185        assert_eq!(info.imports.len(), 2);
1186        assert_eq!(
1187            info.imports[0].imported_name,
1188            ImportedName::Named("foo".to_string())
1189        );
1190        assert_eq!(info.imports[0].source, "./utils");
1191    }
1192
1193    #[test]
1194    fn extracts_namespace_import() {
1195        let info = parse_source("import * as utils from './utils';");
1196        assert_eq!(info.imports.len(), 1);
1197        assert_eq!(info.imports[0].imported_name, ImportedName::Namespace);
1198    }
1199
1200    #[test]
1201    fn extracts_side_effect_import() {
1202        let info = parse_source("import './styles.css';");
1203        assert_eq!(info.imports.len(), 1);
1204        assert_eq!(info.imports[0].imported_name, ImportedName::SideEffect);
1205    }
1206
1207    #[test]
1208    fn extracts_re_exports() {
1209        let info = parse_source("export { foo, bar as baz } from './module';");
1210        assert_eq!(info.re_exports.len(), 2);
1211        assert_eq!(info.re_exports[0].imported_name, "foo");
1212        assert_eq!(info.re_exports[0].exported_name, "foo");
1213        assert_eq!(info.re_exports[1].imported_name, "bar");
1214        assert_eq!(info.re_exports[1].exported_name, "baz");
1215    }
1216
1217    #[test]
1218    fn extracts_star_re_export() {
1219        let info = parse_source("export * from './module';");
1220        assert_eq!(info.re_exports.len(), 1);
1221        assert_eq!(info.re_exports[0].imported_name, "*");
1222        assert_eq!(info.re_exports[0].exported_name, "*");
1223    }
1224
1225    #[test]
1226    fn extracts_dynamic_import() {
1227        let info = parse_source("const mod = import('./lazy');");
1228        assert_eq!(info.dynamic_imports.len(), 1);
1229        assert_eq!(info.dynamic_imports[0].source, "./lazy");
1230    }
1231
1232    #[test]
1233    fn extracts_require_call() {
1234        let info = parse_source("const fs = require('fs');");
1235        assert_eq!(info.require_calls.len(), 1);
1236        assert_eq!(info.require_calls[0].source, "fs");
1237    }
1238
1239    #[test]
1240    fn extracts_type_exports() {
1241        let info = parse_source("export type Foo = string; export interface Bar { x: number; }");
1242        assert_eq!(info.exports.len(), 2);
1243        assert!(info.exports[0].is_type_only);
1244        assert!(info.exports[1].is_type_only);
1245    }
1246
1247    #[test]
1248    fn extracts_type_only_imports() {
1249        let info = parse_source("import type { Foo } from './types';");
1250        assert_eq!(info.imports.len(), 1);
1251        assert!(info.imports[0].is_type_only);
1252    }
1253
1254    #[test]
1255    fn detects_cjs_module_exports() {
1256        let info = parse_source("module.exports = { foo: 1 };");
1257        assert!(info.has_cjs_exports);
1258    }
1259
1260    #[test]
1261    fn detects_cjs_exports_property() {
1262        let info = parse_source("exports.foo = 42;");
1263        assert!(info.has_cjs_exports);
1264        assert_eq!(info.exports.len(), 1);
1265        assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
1266    }
1267
1268    #[test]
1269    fn extracts_static_member_accesses() {
1270        let info = parse_source(
1271            "import { Status, MyClass } from './types';\nconsole.log(Status.Active);\nMyClass.create();",
1272        );
1273        // Should capture: console.log, Status.Active, MyClass.create
1274        assert!(info.member_accesses.len() >= 2);
1275        let has_status_active = info
1276            .member_accesses
1277            .iter()
1278            .any(|a| a.object == "Status" && a.member == "Active");
1279        let has_myclass_create = info
1280            .member_accesses
1281            .iter()
1282            .any(|a| a.object == "MyClass" && a.member == "create");
1283        assert!(has_status_active, "Should capture Status.Active");
1284        assert!(has_myclass_create, "Should capture MyClass.create");
1285    }
1286
1287    #[test]
1288    fn extracts_default_import() {
1289        let info = parse_source("import React from 'react';");
1290        assert_eq!(info.imports.len(), 1);
1291        assert_eq!(info.imports[0].imported_name, ImportedName::Default);
1292        assert_eq!(info.imports[0].local_name, "React");
1293        assert_eq!(info.imports[0].source, "react");
1294    }
1295
1296    #[test]
1297    fn extracts_mixed_import_default_and_named() {
1298        let info = parse_source("import React, { useState, useEffect } from 'react';");
1299        assert_eq!(info.imports.len(), 3);
1300        // Oxc orders: named specifiers first, then default
1301        assert_eq!(info.imports[0].imported_name, ImportedName::Default);
1302        assert_eq!(info.imports[0].local_name, "React");
1303        assert_eq!(
1304            info.imports[1].imported_name,
1305            ImportedName::Named("useState".to_string())
1306        );
1307        assert_eq!(
1308            info.imports[2].imported_name,
1309            ImportedName::Named("useEffect".to_string())
1310        );
1311    }
1312
1313    #[test]
1314    fn extracts_import_with_alias() {
1315        let info = parse_source("import { foo as bar } from './utils';");
1316        assert_eq!(info.imports.len(), 1);
1317        assert_eq!(
1318            info.imports[0].imported_name,
1319            ImportedName::Named("foo".to_string())
1320        );
1321        assert_eq!(info.imports[0].local_name, "bar");
1322    }
1323
1324    #[test]
1325    fn extracts_export_specifier_list() {
1326        let info = parse_source("const foo = 1; const bar = 2; export { foo, bar };");
1327        assert_eq!(info.exports.len(), 2);
1328        assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
1329        assert_eq!(info.exports[1].name, ExportName::Named("bar".to_string()));
1330    }
1331
1332    #[test]
1333    fn extracts_export_with_alias() {
1334        let info = parse_source("const foo = 1; export { foo as myFoo };");
1335        assert_eq!(info.exports.len(), 1);
1336        assert_eq!(info.exports[0].name, ExportName::Named("myFoo".to_string()));
1337    }
1338
1339    #[test]
1340    fn extracts_star_re_export_with_alias() {
1341        let info = parse_source("export * as utils from './utils';");
1342        assert_eq!(info.re_exports.len(), 1);
1343        assert_eq!(info.re_exports[0].imported_name, "*");
1344        assert_eq!(info.re_exports[0].exported_name, "utils");
1345    }
1346
1347    #[test]
1348    fn extracts_export_class_declaration() {
1349        let info = parse_source("export class MyService { name: string = ''; }");
1350        assert_eq!(info.exports.len(), 1);
1351        assert_eq!(
1352            info.exports[0].name,
1353            ExportName::Named("MyService".to_string())
1354        );
1355    }
1356
1357    #[test]
1358    fn class_constructor_is_excluded() {
1359        let info = parse_source("export class Foo { constructor() {} greet() {} }");
1360        assert_eq!(info.exports.len(), 1);
1361        // Members should NOT include constructor
1362        let members: Vec<&str> = info.exports[0]
1363            .members
1364            .iter()
1365            .map(|m| m.name.as_str())
1366            .collect();
1367        assert!(
1368            !members.contains(&"constructor"),
1369            "constructor should be excluded from members"
1370        );
1371        assert!(members.contains(&"greet"), "greet should be included");
1372    }
1373
1374    #[test]
1375    fn extracts_ts_enum_declaration() {
1376        let info = parse_source("export enum Direction { Up, Down, Left, Right }");
1377        assert_eq!(info.exports.len(), 1);
1378        assert_eq!(
1379            info.exports[0].name,
1380            ExportName::Named("Direction".to_string())
1381        );
1382        assert_eq!(info.exports[0].members.len(), 4);
1383        assert_eq!(info.exports[0].members[0].kind, MemberKind::EnumMember);
1384    }
1385
1386    #[test]
1387    fn extracts_ts_module_declaration() {
1388        let info = parse_source("export declare module 'my-module' {}");
1389        assert_eq!(info.exports.len(), 1);
1390        assert!(info.exports[0].is_type_only);
1391    }
1392
1393    #[test]
1394    fn extracts_type_only_named_import() {
1395        let info = parse_source("import { type Foo, Bar } from './types';");
1396        assert_eq!(info.imports.len(), 2);
1397        assert!(info.imports[0].is_type_only);
1398        assert!(!info.imports[1].is_type_only);
1399    }
1400
1401    #[test]
1402    fn extracts_type_re_export() {
1403        let info = parse_source("export type { Foo } from './types';");
1404        assert_eq!(info.re_exports.len(), 1);
1405        assert!(info.re_exports[0].is_type_only);
1406    }
1407
1408    #[test]
1409    fn extracts_destructured_array_export() {
1410        let info = parse_source("export const [first, second] = [1, 2];");
1411        assert_eq!(info.exports.len(), 2);
1412        assert_eq!(info.exports[0].name, ExportName::Named("first".to_string()));
1413        assert_eq!(
1414            info.exports[1].name,
1415            ExportName::Named("second".to_string())
1416        );
1417    }
1418
1419    #[test]
1420    fn extracts_nested_destructured_export() {
1421        let info = parse_source("export const { a, b: { c } } = obj;");
1422        assert_eq!(info.exports.len(), 2);
1423        assert_eq!(info.exports[0].name, ExportName::Named("a".to_string()));
1424        assert_eq!(info.exports[1].name, ExportName::Named("c".to_string()));
1425    }
1426
1427    #[test]
1428    fn extracts_default_export_function_expression() {
1429        let info = parse_source("export default function() { return 42; }");
1430        assert_eq!(info.exports.len(), 1);
1431        assert_eq!(info.exports[0].name, ExportName::Default);
1432    }
1433
1434    #[test]
1435    fn export_name_display() {
1436        assert_eq!(ExportName::Named("foo".to_string()).to_string(), "foo");
1437        assert_eq!(ExportName::Default.to_string(), "default");
1438    }
1439
1440    #[test]
1441    fn no_exports_no_imports() {
1442        let info = parse_source("const x = 1; console.log(x);");
1443        assert!(info.exports.is_empty());
1444        assert!(info.imports.is_empty());
1445        assert!(info.re_exports.is_empty());
1446        assert!(!info.has_cjs_exports);
1447    }
1448
1449    #[test]
1450    fn dynamic_import_non_string_ignored() {
1451        let info = parse_source("const mod = import(variable);");
1452        // Dynamic import with non-string literal should not be captured
1453        assert_eq!(info.dynamic_imports.len(), 0);
1454    }
1455
1456    #[test]
1457    fn multiple_require_calls() {
1458        let info =
1459            parse_source("const a = require('a'); const b = require('b'); const c = require('c');");
1460        assert_eq!(info.require_calls.len(), 3);
1461    }
1462
1463    #[test]
1464    fn extracts_ts_interface() {
1465        let info = parse_source("export interface Props { name: string; age: number; }");
1466        assert_eq!(info.exports.len(), 1);
1467        assert_eq!(info.exports[0].name, ExportName::Named("Props".to_string()));
1468        assert!(info.exports[0].is_type_only);
1469    }
1470
1471    #[test]
1472    fn extracts_ts_type_alias() {
1473        let info = parse_source("export type ID = string | number;");
1474        assert_eq!(info.exports.len(), 1);
1475        assert_eq!(info.exports[0].name, ExportName::Named("ID".to_string()));
1476        assert!(info.exports[0].is_type_only);
1477    }
1478
1479    #[test]
1480    fn extracts_member_accesses_inside_exported_functions() {
1481        let info = parse_source(
1482            "import { Color } from './types';\nexport const isRed = (c: Color) => c === Color.Red;",
1483        );
1484        let has_color_red = info
1485            .member_accesses
1486            .iter()
1487            .any(|a| a.object == "Color" && a.member == "Red");
1488        assert!(
1489            has_color_red,
1490            "Should capture Color.Red inside exported function body"
1491        );
1492    }
1493
1494    // ── Whole-object use detection ──────────────────────────────
1495
1496    #[test]
1497    fn detects_object_values_whole_use() {
1498        let info = parse_source("import { Status } from './types';\nObject.values(Status);");
1499        assert!(info.whole_object_uses.contains(&"Status".to_string()));
1500    }
1501
1502    #[test]
1503    fn detects_object_keys_whole_use() {
1504        let info = parse_source("import { Dir } from './types';\nObject.keys(Dir);");
1505        assert!(info.whole_object_uses.contains(&"Dir".to_string()));
1506    }
1507
1508    #[test]
1509    fn detects_object_entries_whole_use() {
1510        let info = parse_source("import { E } from './types';\nObject.entries(E);");
1511        assert!(info.whole_object_uses.contains(&"E".to_string()));
1512    }
1513
1514    #[test]
1515    fn detects_for_in_whole_use() {
1516        let info = parse_source("import { Color } from './types';\nfor (const k in Color) {}");
1517        assert!(info.whole_object_uses.contains(&"Color".to_string()));
1518    }
1519
1520    #[test]
1521    fn detects_spread_whole_use() {
1522        let info = parse_source("import { X } from './types';\nconst y = { ...X };");
1523        assert!(info.whole_object_uses.contains(&"X".to_string()));
1524    }
1525
1526    #[test]
1527    fn computed_member_string_literal_resolves() {
1528        let info = parse_source("import { Status } from './types';\nStatus[\"Active\"];");
1529        let has_access = info
1530            .member_accesses
1531            .iter()
1532            .any(|a| a.object == "Status" && a.member == "Active");
1533        assert!(
1534            has_access,
1535            "Status[\"Active\"] should resolve to a static member access"
1536        );
1537    }
1538
1539    #[test]
1540    fn computed_member_variable_marks_whole_use() {
1541        let info = parse_source("import { Status } from './types';\nconst k = 'foo';\nStatus[k];");
1542        assert!(info.whole_object_uses.contains(&"Status".to_string()));
1543    }
1544
1545    // ── Dynamic import pattern extraction ───────────────────────
1546
1547    #[test]
1548    fn extracts_template_literal_dynamic_import_pattern() {
1549        let info = parse_source("const m = import(`./locales/${lang}.json`);");
1550        assert_eq!(info.dynamic_import_patterns.len(), 1);
1551        assert_eq!(info.dynamic_import_patterns[0].prefix, "./locales/");
1552        assert_eq!(
1553            info.dynamic_import_patterns[0].suffix,
1554            Some(".json".to_string())
1555        );
1556    }
1557
1558    #[test]
1559    fn extracts_concat_dynamic_import_pattern() {
1560        let info = parse_source("const m = import('./pages/' + name);");
1561        assert_eq!(info.dynamic_import_patterns.len(), 1);
1562        assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/");
1563        assert!(info.dynamic_import_patterns[0].suffix.is_none());
1564    }
1565
1566    #[test]
1567    fn extracts_concat_with_suffix() {
1568        let info = parse_source("const m = import('./pages/' + name + '.tsx');");
1569        assert_eq!(info.dynamic_import_patterns.len(), 1);
1570        assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/");
1571        assert_eq!(
1572            info.dynamic_import_patterns[0].suffix,
1573            Some(".tsx".to_string())
1574        );
1575    }
1576
1577    #[test]
1578    fn no_substitution_template_treated_as_exact() {
1579        let info = parse_source("const m = import(`./exact-module`);");
1580        assert_eq!(info.dynamic_imports.len(), 1);
1581        assert_eq!(info.dynamic_imports[0].source, "./exact-module");
1582        assert!(info.dynamic_import_patterns.is_empty());
1583    }
1584
1585    #[test]
1586    fn fully_dynamic_import_still_ignored() {
1587        let info = parse_source("const m = import(variable);");
1588        assert!(info.dynamic_imports.is_empty());
1589        assert!(info.dynamic_import_patterns.is_empty());
1590    }
1591
1592    #[test]
1593    fn non_relative_template_ignored() {
1594        let info = parse_source("const m = import(`lodash/${fn}`);");
1595        assert!(info.dynamic_import_patterns.is_empty());
1596    }
1597
1598    #[test]
1599    fn multi_expression_template_uses_globstar() {
1600        // `./plugins/${cat}/${name}.js` has 2 expressions → prefix gets **/
1601        let info = parse_source("const m = import(`./plugins/${cat}/${name}.js`);");
1602        assert_eq!(info.dynamic_import_patterns.len(), 1);
1603        assert_eq!(info.dynamic_import_patterns[0].prefix, "./plugins/**/");
1604        assert_eq!(
1605            info.dynamic_import_patterns[0].suffix,
1606            Some(".js".to_string())
1607        );
1608    }
1609
1610    // ── Vue/Svelte SFC parsing ──────────────────────────────────
1611
1612    fn parse_sfc(source: &str, filename: &str) -> ModuleInfo {
1613        parse_source_to_module(FileId(0), Path::new(filename), source, 0)
1614    }
1615
1616    #[test]
1617    fn extracts_vue_script_imports() {
1618        let info = parse_sfc(
1619            r#"
1620<script lang="ts">
1621import { ref } from 'vue';
1622import { helper } from './utils';
1623export default {};
1624</script>
1625<template><div></div></template>
1626"#,
1627            "App.vue",
1628        );
1629        assert_eq!(info.imports.len(), 2);
1630        assert!(info.imports.iter().any(|i| i.source == "vue"));
1631        assert!(info.imports.iter().any(|i| i.source == "./utils"));
1632    }
1633
1634    #[test]
1635    fn extracts_vue_script_setup_imports() {
1636        let info = parse_sfc(
1637            r#"
1638<script setup lang="ts">
1639import { ref } from 'vue';
1640const count = ref(0);
1641</script>
1642"#,
1643            "Comp.vue",
1644        );
1645        assert_eq!(info.imports.len(), 1);
1646        assert_eq!(info.imports[0].source, "vue");
1647    }
1648
1649    #[test]
1650    fn extracts_vue_both_scripts() {
1651        let info = parse_sfc(
1652            r#"
1653<script lang="ts">
1654import { defineComponent } from 'vue';
1655export default defineComponent({});
1656</script>
1657<script setup lang="ts">
1658import { ref } from 'vue';
1659const count = ref(0);
1660</script>
1661"#,
1662            "Dual.vue",
1663        );
1664        assert!(info.imports.len() >= 2);
1665    }
1666
1667    #[test]
1668    fn extracts_svelte_script_imports() {
1669        let info = parse_sfc(
1670            r#"
1671<script lang="ts">
1672import { onMount } from 'svelte';
1673import { helper } from './utils';
1674</script>
1675<p>Hello</p>
1676"#,
1677            "App.svelte",
1678        );
1679        assert_eq!(info.imports.len(), 2);
1680        assert!(info.imports.iter().any(|i| i.source == "svelte"));
1681        assert!(info.imports.iter().any(|i| i.source == "./utils"));
1682    }
1683
1684    #[test]
1685    fn vue_no_script_returns_empty() {
1686        let info = parse_sfc(
1687            "<template><div></div></template><style>div {}</style>",
1688            "NoScript.vue",
1689        );
1690        assert!(info.imports.is_empty());
1691        assert!(info.exports.is_empty());
1692    }
1693
1694    #[test]
1695    fn vue_js_default_lang() {
1696        let info = parse_sfc(
1697            r#"
1698<script>
1699import { createApp } from 'vue';
1700export default {};
1701</script>
1702"#,
1703            "JsVue.vue",
1704        );
1705        assert_eq!(info.imports.len(), 1);
1706    }
1707
1708    // ── import.meta.glob / require.context ──────────────────────
1709
1710    #[test]
1711    fn extracts_import_meta_glob_pattern() {
1712        let info = parse_source("const mods = import.meta.glob('./components/*.tsx');");
1713        assert_eq!(info.dynamic_import_patterns.len(), 1);
1714        assert_eq!(info.dynamic_import_patterns[0].prefix, "./components/*.tsx");
1715    }
1716
1717    #[test]
1718    fn extracts_import_meta_glob_array() {
1719        let info =
1720            parse_source("const mods = import.meta.glob(['./pages/*.ts', './layouts/*.ts']);");
1721        assert_eq!(info.dynamic_import_patterns.len(), 2);
1722        assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/*.ts");
1723        assert_eq!(info.dynamic_import_patterns[1].prefix, "./layouts/*.ts");
1724    }
1725
1726    #[test]
1727    fn extracts_require_context_pattern() {
1728        let info = parse_source("const ctx = require.context('./icons', false);");
1729        assert_eq!(info.dynamic_import_patterns.len(), 1);
1730        assert_eq!(info.dynamic_import_patterns[0].prefix, "./icons/");
1731    }
1732
1733    #[test]
1734    fn extracts_require_context_recursive() {
1735        let info = parse_source("const ctx = require.context('./icons', true);");
1736        assert_eq!(info.dynamic_import_patterns.len(), 1);
1737        assert_eq!(info.dynamic_import_patterns[0].prefix, "./icons/**/");
1738    }
1739
1740    // ── Dynamic import namespace tracking ────────────────────────
1741
1742    #[test]
1743    fn dynamic_import_await_captures_local_name() {
1744        let info = parse_source(
1745            "async function f() { const mod = await import('./service'); mod.doStuff(); }",
1746        );
1747        assert_eq!(info.dynamic_imports.len(), 1);
1748        assert_eq!(info.dynamic_imports[0].source, "./service");
1749        assert_eq!(info.dynamic_imports[0].local_name, Some("mod".to_string()));
1750        assert!(info.dynamic_imports[0].destructured_names.is_empty());
1751    }
1752
1753    #[test]
1754    fn dynamic_import_without_await_captures_local_name() {
1755        // `const mod = import('./service')` (promise, no await)
1756        let info = parse_source("const mod = import('./service');");
1757        assert_eq!(info.dynamic_imports.len(), 1);
1758        assert_eq!(info.dynamic_imports[0].source, "./service");
1759        assert_eq!(info.dynamic_imports[0].local_name, Some("mod".to_string()));
1760    }
1761
1762    #[test]
1763    fn dynamic_import_destructured_captures_names() {
1764        let info =
1765            parse_source("async function f() { const { foo, bar } = await import('./module'); }");
1766        assert_eq!(info.dynamic_imports.len(), 1);
1767        assert_eq!(info.dynamic_imports[0].source, "./module");
1768        assert!(info.dynamic_imports[0].local_name.is_none());
1769        assert_eq!(
1770            info.dynamic_imports[0].destructured_names,
1771            vec!["foo", "bar"]
1772        );
1773    }
1774
1775    #[test]
1776    fn dynamic_import_destructured_with_rest_is_namespace() {
1777        let info = parse_source(
1778            "async function f() { const { foo, ...rest } = await import('./module'); }",
1779        );
1780        assert_eq!(info.dynamic_imports.len(), 1);
1781        assert_eq!(info.dynamic_imports[0].source, "./module");
1782        // Has rest element → conservative namespace (no destructured_names, no local_name)
1783        assert!(info.dynamic_imports[0].local_name.is_none());
1784        assert!(info.dynamic_imports[0].destructured_names.is_empty());
1785    }
1786
1787    #[test]
1788    fn dynamic_import_side_effect_only() {
1789        // No variable assignment → side-effect import
1790        let info = parse_source("async function f() { await import('./side-effect'); }");
1791        assert_eq!(info.dynamic_imports.len(), 1);
1792        assert_eq!(info.dynamic_imports[0].source, "./side-effect");
1793        assert!(info.dynamic_imports[0].local_name.is_none());
1794        assert!(info.dynamic_imports[0].destructured_names.is_empty());
1795    }
1796
1797    #[test]
1798    fn dynamic_import_no_duplicate_entries() {
1799        // When handled via visit_variable_declaration, visit_import_expression should skip it.
1800        // There should be exactly 1 DynamicImportInfo, not 2.
1801        let info = parse_source("async function f() { const mod = await import('./service'); }");
1802        assert_eq!(info.dynamic_imports.len(), 1);
1803    }
1804}