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