Skip to main content

fallow_graph/resolve/
mod.rs

1//! Import specifier resolution using `oxc_resolver`.
2//!
3//! Resolves all import specifiers across all modules in parallel, mapping each to
4//! an internal file, npm package, or unresolvable target. Includes support for
5//! tsconfig path aliases, pnpm virtual store paths, React Native platform extensions,
6//! and dynamic import pattern matching via glob.
7
8pub(crate) mod fallbacks;
9mod path_info;
10mod react_native;
11mod specifier;
12mod types;
13
14pub use path_info::{extract_package_name, is_bare_specifier, is_path_alias};
15pub use types::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
16
17use std::path::{Path, PathBuf};
18
19use rayon::prelude::*;
20use rustc_hash::FxHashMap;
21
22use oxc_span::Span;
23
24use fallow_types::discover::{DiscoveredFile, FileId};
25use fallow_types::extract::{
26    DynamicImportInfo, DynamicImportPattern, ImportInfo, ImportedName, ModuleInfo, ReExportInfo,
27    RequireCallInfo,
28};
29
30use fallbacks::make_glob_from_pattern;
31use specifier::{create_resolver, resolve_specifier};
32use types::ResolveContext;
33
34/// Resolve all imports across all modules in parallel.
35pub fn resolve_all_imports(
36    modules: &[ModuleInfo],
37    files: &[DiscoveredFile],
38    workspaces: &[fallow_config::WorkspaceInfo],
39    active_plugins: &[String],
40    path_aliases: &[(String, String)],
41    root: &Path,
42) -> Vec<ResolvedModule> {
43    // Build workspace name → root index for pnpm store fallback.
44    // Canonicalize roots to match path_to_id (which uses canonical paths).
45    // Without this, macOS /var → /private/var and similar platform symlinks
46    // cause workspace roots to mismatch canonical file paths.
47    let canonical_ws_roots: Vec<PathBuf> = workspaces
48        .par_iter()
49        .map(|ws| ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone()))
50        .collect();
51    let workspace_roots: FxHashMap<&str, &Path> = workspaces
52        .iter()
53        .zip(canonical_ws_roots.iter())
54        .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
55        .collect();
56
57    // Check if project root is already canonical (no symlinks in path).
58    // When true, raw paths == canonical paths for files under root, so we can skip
59    // the upfront bulk canonicalize() of all source files (21k+ syscalls on large projects).
60    // A lazy CanonicalFallback handles the rare intra-project symlink case.
61    let root_is_canonical = root.canonicalize().is_ok_and(|c| c == root);
62
63    // Pre-compute canonical paths ONCE for all files in parallel (avoiding repeated syscalls).
64    // Skipped when root is canonical — the lazy fallback below handles edge cases.
65    let canonical_paths: Vec<PathBuf> = if root_is_canonical {
66        Vec::new()
67    } else {
68        files
69            .par_iter()
70            .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
71            .collect()
72    };
73
74    // Primary path → FileId index. When root is canonical, uses raw paths (fast).
75    // Otherwise uses pre-computed canonical paths (correct for all symlink configurations).
76    let path_to_id: FxHashMap<&Path, FileId> = if root_is_canonical {
77        files.iter().map(|f| (f.path.as_path(), f.id)).collect()
78    } else {
79        canonical_paths
80            .iter()
81            .enumerate()
82            .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
83            .collect()
84    };
85
86    // Also index by non-canonical path for fallback lookups
87    let raw_path_to_id: FxHashMap<&Path, FileId> =
88        files.iter().map(|f| (f.path.as_path(), f.id)).collect();
89
90    // FileIds are sequential 0..n, so direct array indexing is faster than FxHashMap.
91    let file_paths: Vec<&Path> = files.iter().map(|f| f.path.as_path()).collect();
92
93    // Create resolver ONCE and share across threads (oxc_resolver::Resolver is Send + Sync)
94    let resolver = create_resolver(active_plugins);
95
96    // Lazy canonical fallback — only needed when root is canonical (path_to_id uses raw paths).
97    // When root is NOT canonical, path_to_id already uses canonical paths, no fallback needed.
98    let canonical_fallback = if root_is_canonical {
99        Some(types::CanonicalFallback::new(files))
100    } else {
101        None
102    };
103
104    // Shared resolution context — avoids passing 6 arguments to every resolve_specifier call
105    let ctx = ResolveContext {
106        resolver: &resolver,
107        path_to_id: &path_to_id,
108        raw_path_to_id: &raw_path_to_id,
109        workspace_roots: &workspace_roots,
110        path_aliases,
111        root,
112        canonical_fallback: canonical_fallback.as_ref(),
113    };
114
115    // Resolve in parallel — shared resolver instance.
116    // Each file resolves its own imports independently (no shared bare specifier cache).
117    // oxc_resolver's internal caches (package.json, tsconfig, directory entries) are
118    // shared across threads for performance.
119    let mut resolved: Vec<ResolvedModule> = modules
120        .par_iter()
121        .filter_map(|module| {
122            let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
123                tracing::warn!(
124                    file_id = module.file_id.0,
125                    "Skipping module with unknown file_id during resolution"
126                );
127                return None;
128            };
129
130            let mut all_imports = resolve_static_imports(&ctx, file_path, &module.imports);
131            all_imports.extend(resolve_require_imports(
132                &ctx,
133                file_path,
134                &module.require_calls,
135            ));
136
137            let from_dir = if canonical_paths.is_empty() {
138                // Root is canonical — raw paths are canonical
139                file_path.parent().unwrap_or(file_path)
140            } else {
141                canonical_paths
142                    .get(module.file_id.0 as usize)
143                    .and_then(|p| p.parent())
144                    .unwrap_or(file_path)
145            };
146
147            Some(ResolvedModule {
148                file_id: module.file_id,
149                path: file_path.to_path_buf(),
150                exports: module.exports.clone(),
151                re_exports: resolve_re_exports(&ctx, file_path, &module.re_exports),
152                resolved_imports: all_imports,
153                resolved_dynamic_imports: resolve_dynamic_imports(
154                    &ctx,
155                    file_path,
156                    &module.dynamic_imports,
157                ),
158                resolved_dynamic_patterns: resolve_dynamic_patterns(
159                    from_dir,
160                    &module.dynamic_import_patterns,
161                    &canonical_paths,
162                    files,
163                ),
164                member_accesses: module.member_accesses.clone(),
165                whole_object_uses: module.whole_object_uses.clone(),
166                has_cjs_exports: module.has_cjs_exports,
167                unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
168            })
169        })
170        .collect();
171
172    apply_specifier_upgrades(&mut resolved);
173
174    resolved
175}
176
177/// Resolve standard ES module imports (`import x from './y'`).
178fn resolve_static_imports(
179    ctx: &ResolveContext,
180    file_path: &Path,
181    imports: &[ImportInfo],
182) -> Vec<ResolvedImport> {
183    imports
184        .iter()
185        .map(|imp| ResolvedImport {
186            info: imp.clone(),
187            target: resolve_specifier(ctx, file_path, &imp.source),
188        })
189        .collect()
190}
191
192/// Resolve dynamic `import()` calls, expanding destructured names into individual imports.
193fn resolve_dynamic_imports(
194    ctx: &ResolveContext,
195    file_path: &Path,
196    dynamic_imports: &[DynamicImportInfo],
197) -> Vec<ResolvedImport> {
198    dynamic_imports
199        .iter()
200        .flat_map(|imp| resolve_single_dynamic_import(ctx, file_path, imp))
201        .collect()
202}
203
204/// Convert a single dynamic import into one or more `ResolvedImport` entries.
205fn resolve_single_dynamic_import(
206    ctx: &ResolveContext,
207    file_path: &Path,
208    imp: &DynamicImportInfo,
209) -> Vec<ResolvedImport> {
210    let target = resolve_specifier(ctx, file_path, &imp.source);
211
212    if !imp.destructured_names.is_empty() {
213        // `const { a, b } = await import('./x')` -> Named imports
214        return imp
215            .destructured_names
216            .iter()
217            .map(|name| ResolvedImport {
218                info: ImportInfo {
219                    source: imp.source.clone(),
220                    imported_name: ImportedName::Named(name.clone()),
221                    local_name: name.clone(),
222                    is_type_only: false,
223                    span: imp.span,
224                    source_span: Span::default(),
225                },
226                target: target.clone(),
227            })
228            .collect();
229    }
230
231    if imp.local_name.is_some() {
232        // `const mod = await import('./x')` -> Namespace with local_name
233        return vec![ResolvedImport {
234            info: ImportInfo {
235                source: imp.source.clone(),
236                imported_name: ImportedName::Namespace,
237                local_name: imp.local_name.clone().unwrap_or_default(),
238                is_type_only: false,
239                span: imp.span,
240                source_span: Span::default(),
241            },
242            target,
243        }];
244    }
245
246    // Side-effect only: `await import('./x')` with no assignment
247    vec![ResolvedImport {
248        info: ImportInfo {
249            source: imp.source.clone(),
250            imported_name: ImportedName::SideEffect,
251            local_name: String::new(),
252            is_type_only: false,
253            span: imp.span,
254            source_span: Span::default(),
255        },
256        target,
257    }]
258}
259
260/// Resolve re-export sources (`export { x } from './y'`).
261fn resolve_re_exports(
262    ctx: &ResolveContext,
263    file_path: &Path,
264    re_exports: &[ReExportInfo],
265) -> Vec<ResolvedReExport> {
266    re_exports
267        .iter()
268        .map(|re| ResolvedReExport {
269            info: re.clone(),
270            target: resolve_specifier(ctx, file_path, &re.source),
271        })
272        .collect()
273}
274
275/// Resolve CommonJS `require()` calls.
276/// Destructured requires become Named imports; others become Namespace (conservative).
277fn resolve_require_imports(
278    ctx: &ResolveContext,
279    file_path: &Path,
280    require_calls: &[RequireCallInfo],
281) -> Vec<ResolvedImport> {
282    require_calls
283        .iter()
284        .flat_map(|req| resolve_single_require(ctx, file_path, req))
285        .collect()
286}
287
288/// Convert a single `require()` call into one or more `ResolvedImport` entries.
289fn resolve_single_require(
290    ctx: &ResolveContext,
291    file_path: &Path,
292    req: &RequireCallInfo,
293) -> Vec<ResolvedImport> {
294    let target = resolve_specifier(ctx, file_path, &req.source);
295
296    if req.destructured_names.is_empty() {
297        return vec![ResolvedImport {
298            info: ImportInfo {
299                source: req.source.clone(),
300                imported_name: ImportedName::Namespace,
301                local_name: req.local_name.clone().unwrap_or_default(),
302                is_type_only: false,
303                span: req.span,
304                source_span: Span::default(),
305            },
306            target,
307        }];
308    }
309
310    req.destructured_names
311        .iter()
312        .map(|name| ResolvedImport {
313            info: ImportInfo {
314                source: req.source.clone(),
315                imported_name: ImportedName::Named(name.clone()),
316                local_name: name.clone(),
317                is_type_only: false,
318                span: req.span,
319                source_span: Span::default(),
320            },
321            target: target.clone(),
322        })
323        .collect()
324}
325
326/// Resolve dynamic import patterns via glob matching against discovered files.
327/// When canonical paths are available, uses those for matching. Otherwise falls
328/// back to raw file paths from `files` (avoids allocating a separate PathBuf vec).
329fn resolve_dynamic_patterns(
330    from_dir: &Path,
331    patterns: &[DynamicImportPattern],
332    canonical_paths: &[PathBuf],
333    files: &[DiscoveredFile],
334) -> Vec<(DynamicImportPattern, Vec<FileId>)> {
335    patterns
336        .iter()
337        .filter_map(|pattern| {
338            let glob_str = make_glob_from_pattern(pattern);
339            let matcher = globset::Glob::new(&glob_str)
340                .ok()
341                .map(|g| g.compile_matcher())?;
342            let matched: Vec<FileId> = if canonical_paths.is_empty() {
343                // Root is canonical — use raw file paths directly (no extra allocation)
344                files
345                    .iter()
346                    .filter(|f| {
347                        f.path.strip_prefix(from_dir).is_ok_and(|relative| {
348                            let rel_str = format!("./{}", relative.to_string_lossy());
349                            matcher.is_match(&rel_str)
350                        })
351                    })
352                    .map(|f| f.id)
353                    .collect()
354            } else {
355                canonical_paths
356                    .iter()
357                    .enumerate()
358                    .filter(|(_idx, canonical)| {
359                        canonical.strip_prefix(from_dir).is_ok_and(|relative| {
360                            let rel_str = format!("./{}", relative.to_string_lossy());
361                            matcher.is_match(&rel_str)
362                        })
363                    })
364                    .map(|(idx, _)| files[idx].id)
365                    .collect()
366            };
367            if matched.is_empty() {
368                None
369            } else {
370                Some((pattern.clone(), matched))
371            }
372        })
373        .collect()
374}
375
376/// Post-resolution pass: deterministic specifier upgrade.
377///
378/// With `TsconfigDiscovery::Auto`, the same bare specifier (e.g., `preact/hooks`)
379/// may resolve to `InternalModule` from files under a tsconfig with path aliases
380/// but `NpmPackage` from files without such aliases. The parallel resolution cache
381/// makes the per-file result depend on which thread resolved first (non-deterministic).
382///
383/// Scans all resolved imports/re-exports to find bare specifiers where ANY file resolved
384/// to `InternalModule`. For those specifiers, upgrades all `NpmPackage` results to
385/// `InternalModule`. This is correct because if any tsconfig context maps a specifier to
386/// a project source file, that source file IS the origin of the package.
387///
388/// Note: if two tsconfigs map the same specifier to different `FileId`s, the first one
389/// encountered (by module order = `FileId` order) wins. This is deterministic but may be
390/// imprecise for that edge case — both files get connected regardless.
391fn apply_specifier_upgrades(resolved: &mut [ResolvedModule]) {
392    let mut specifier_upgrades: FxHashMap<String, FileId> = FxHashMap::default();
393    for module in resolved.iter() {
394        for imp in module
395            .resolved_imports
396            .iter()
397            .chain(module.resolved_dynamic_imports.iter())
398        {
399            if is_bare_specifier(&imp.info.source)
400                && let ResolveResult::InternalModule(file_id) = &imp.target
401            {
402                specifier_upgrades
403                    .entry(imp.info.source.clone())
404                    .or_insert(*file_id);
405            }
406        }
407        for re in &module.re_exports {
408            if is_bare_specifier(&re.info.source)
409                && let ResolveResult::InternalModule(file_id) = &re.target
410            {
411                specifier_upgrades
412                    .entry(re.info.source.clone())
413                    .or_insert(*file_id);
414            }
415        }
416    }
417
418    if specifier_upgrades.is_empty() {
419        return;
420    }
421
422    // Apply upgrades: replace NpmPackage with InternalModule for matched specifiers
423    for module in resolved.iter_mut() {
424        for imp in module
425            .resolved_imports
426            .iter_mut()
427            .chain(module.resolved_dynamic_imports.iter_mut())
428        {
429            if matches!(imp.target, ResolveResult::NpmPackage(_))
430                && let Some(&file_id) = specifier_upgrades.get(&imp.info.source)
431            {
432                imp.target = ResolveResult::InternalModule(file_id);
433            }
434        }
435        for re in &mut module.re_exports {
436            if matches!(re.target, ResolveResult::NpmPackage(_))
437                && let Some(&file_id) = specifier_upgrades.get(&re.info.source)
438            {
439                re.target = ResolveResult::InternalModule(file_id);
440            }
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use rustc_hash::FxHashSet;
448
449    use super::*;
450    use oxc_span::Span;
451
452    // -----------------------------------------------------------------------
453    // Helpers
454    // -----------------------------------------------------------------------
455
456    fn dummy_span() -> Span {
457        Span::new(0, 0)
458    }
459
460    /// Build a minimal `ResolveContext` backed by a real resolver but with
461    /// empty lookup tables. Every specifier resolves to `NpmPackage` or
462    /// `Unresolvable`, which is fine — the tests focus on how helper functions
463    /// *transform* inputs into `ResolvedImport` / `ResolvedReExport` structs.
464    ///
465    /// Under Miri this is a no-op: `oxc_resolver` uses the `statx` syscall
466    /// (via `rustix`) which Miri does not support.
467    #[cfg(not(miri))]
468    fn with_empty_ctx<F: FnOnce(&ResolveContext)>(f: F) {
469        let resolver = specifier::create_resolver(&[]);
470        let path_to_id = FxHashMap::default();
471        let raw_path_to_id = FxHashMap::default();
472        let workspace_roots = FxHashMap::default();
473        let root = PathBuf::from("/project");
474        let ctx = ResolveContext {
475            resolver: &resolver,
476            path_to_id: &path_to_id,
477            raw_path_to_id: &raw_path_to_id,
478            workspace_roots: &workspace_roots,
479            path_aliases: &[],
480            root: &root,
481            canonical_fallback: None,
482        };
483        f(&ctx);
484    }
485
486    #[cfg(miri)]
487    fn with_empty_ctx<F: FnOnce(&ResolveContext)>(_f: F) {
488        // oxc_resolver uses statx syscall unsupported by Miri — skip.
489    }
490
491    fn make_import(source: &str, imported: ImportedName, local: &str) -> ImportInfo {
492        ImportInfo {
493            source: source.to_string(),
494            imported_name: imported,
495            local_name: local.to_string(),
496            is_type_only: false,
497            span: dummy_span(),
498            source_span: Span::default(),
499        }
500    }
501
502    fn make_re_export(source: &str, imported: &str, exported: &str) -> ReExportInfo {
503        ReExportInfo {
504            source: source.to_string(),
505            imported_name: imported.to_string(),
506            exported_name: exported.to_string(),
507            is_type_only: false,
508        }
509    }
510
511    fn make_dynamic(
512        source: &str,
513        destructured: Vec<&str>,
514        local_name: Option<&str>,
515    ) -> DynamicImportInfo {
516        DynamicImportInfo {
517            source: source.to_string(),
518            span: dummy_span(),
519            destructured_names: destructured.into_iter().map(String::from).collect(),
520            local_name: local_name.map(String::from),
521        }
522    }
523
524    fn make_require(
525        source: &str,
526        destructured: Vec<&str>,
527        local_name: Option<&str>,
528    ) -> RequireCallInfo {
529        RequireCallInfo {
530            source: source.to_string(),
531            span: dummy_span(),
532            destructured_names: destructured.into_iter().map(String::from).collect(),
533            local_name: local_name.map(String::from),
534        }
535    }
536
537    /// Build a minimal `ResolvedModule` for `apply_specifier_upgrades` tests.
538    fn make_resolved_module(
539        file_id: u32,
540        imports: Vec<ResolvedImport>,
541        dynamic_imports: Vec<ResolvedImport>,
542        re_exports: Vec<ResolvedReExport>,
543    ) -> ResolvedModule {
544        ResolvedModule {
545            file_id: FileId(file_id),
546            path: PathBuf::from(format!("/project/src/file_{file_id}.ts")),
547            exports: vec![],
548            re_exports,
549            resolved_imports: imports,
550            resolved_dynamic_imports: dynamic_imports,
551            resolved_dynamic_patterns: vec![],
552            member_accesses: vec![],
553            whole_object_uses: vec![],
554            has_cjs_exports: false,
555            unused_import_bindings: FxHashSet::default(),
556        }
557    }
558
559    fn make_resolved_import(source: &str, target: ResolveResult) -> ResolvedImport {
560        ResolvedImport {
561            info: make_import(source, ImportedName::Named("x".into()), "x"),
562            target,
563        }
564    }
565
566    fn make_resolved_re_export(source: &str, target: ResolveResult) -> ResolvedReExport {
567        ResolvedReExport {
568            info: make_re_export(source, "x", "x"),
569            target,
570        }
571    }
572
573    // -----------------------------------------------------------------------
574    // resolve_static_imports
575    // -----------------------------------------------------------------------
576
577    #[test]
578    fn static_imports_named() {
579        with_empty_ctx(|ctx| {
580            let imports = vec![make_import(
581                "react",
582                ImportedName::Named("useState".into()),
583                "useState",
584            )];
585            let file = Path::new("/project/src/app.ts");
586            let result = resolve_static_imports(ctx, file, &imports);
587
588            assert_eq!(result.len(), 1);
589            assert_eq!(result[0].info.source, "react");
590            assert!(matches!(
591                result[0].info.imported_name,
592                ImportedName::Named(ref n) if n == "useState"
593            ));
594        });
595    }
596
597    #[test]
598    fn static_imports_default() {
599        with_empty_ctx(|ctx| {
600            let imports = vec![make_import("react", ImportedName::Default, "React")];
601            let file = Path::new("/project/src/app.ts");
602            let result = resolve_static_imports(ctx, file, &imports);
603
604            assert_eq!(result.len(), 1);
605            assert!(matches!(
606                result[0].info.imported_name,
607                ImportedName::Default
608            ));
609            assert_eq!(result[0].info.local_name, "React");
610        });
611    }
612
613    #[test]
614    fn static_imports_namespace() {
615        with_empty_ctx(|ctx| {
616            let imports = vec![make_import("lodash", ImportedName::Namespace, "_")];
617            let file = Path::new("/project/src/utils.ts");
618            let result = resolve_static_imports(ctx, file, &imports);
619
620            assert_eq!(result.len(), 1);
621            assert!(matches!(
622                result[0].info.imported_name,
623                ImportedName::Namespace
624            ));
625            assert_eq!(result[0].info.local_name, "_");
626        });
627    }
628
629    #[test]
630    fn static_imports_side_effect() {
631        with_empty_ctx(|ctx| {
632            let imports = vec![make_import("./styles.css", ImportedName::SideEffect, "")];
633            let file = Path::new("/project/src/app.ts");
634            let result = resolve_static_imports(ctx, file, &imports);
635
636            assert_eq!(result.len(), 1);
637            assert!(matches!(
638                result[0].info.imported_name,
639                ImportedName::SideEffect
640            ));
641            assert_eq!(result[0].info.local_name, "");
642        });
643    }
644
645    #[test]
646    fn static_imports_empty_list() {
647        with_empty_ctx(|ctx| {
648            let file = Path::new("/project/src/app.ts");
649            let result = resolve_static_imports(ctx, file, &[]);
650            assert!(result.is_empty());
651        });
652    }
653
654    #[test]
655    fn static_imports_multiple() {
656        with_empty_ctx(|ctx| {
657            let imports = vec![
658                make_import("react", ImportedName::Default, "React"),
659                make_import("react", ImportedName::Named("useState".into()), "useState"),
660                make_import("lodash", ImportedName::Namespace, "_"),
661            ];
662            let file = Path::new("/project/src/app.ts");
663            let result = resolve_static_imports(ctx, file, &imports);
664
665            assert_eq!(result.len(), 3);
666            assert_eq!(result[0].info.source, "react");
667            assert_eq!(result[1].info.source, "react");
668            assert_eq!(result[2].info.source, "lodash");
669        });
670    }
671
672    #[test]
673    fn static_imports_preserves_type_only() {
674        with_empty_ctx(|ctx| {
675            let imports = vec![ImportInfo {
676                source: "react".into(),
677                imported_name: ImportedName::Named("FC".into()),
678                local_name: "FC".into(),
679                is_type_only: true,
680                span: dummy_span(),
681                source_span: Span::default(),
682            }];
683            let file = Path::new("/project/src/app.ts");
684            let result = resolve_static_imports(ctx, file, &imports);
685
686            assert_eq!(result.len(), 1);
687            assert!(result[0].info.is_type_only);
688        });
689    }
690
691    // -----------------------------------------------------------------------
692    // resolve_single_dynamic_import
693    // -----------------------------------------------------------------------
694
695    #[test]
696    fn dynamic_import_with_destructured_names() {
697        with_empty_ctx(|ctx| {
698            let imp = make_dynamic("./utils", vec!["foo", "bar"], None);
699            let file = Path::new("/project/src/app.ts");
700            let result = resolve_single_dynamic_import(ctx, file, &imp);
701
702            assert_eq!(result.len(), 2);
703            assert!(matches!(
704                result[0].info.imported_name,
705                ImportedName::Named(ref n) if n == "foo"
706            ));
707            assert_eq!(result[0].info.local_name, "foo");
708            assert!(matches!(
709                result[1].info.imported_name,
710                ImportedName::Named(ref n) if n == "bar"
711            ));
712            assert_eq!(result[1].info.local_name, "bar");
713            // Both should have the same source
714            assert_eq!(result[0].info.source, "./utils");
715            assert_eq!(result[1].info.source, "./utils");
716            // Both should be non-type-only
717            assert!(!result[0].info.is_type_only);
718            assert!(!result[1].info.is_type_only);
719        });
720    }
721
722    #[test]
723    fn dynamic_import_namespace_with_local_name() {
724        with_empty_ctx(|ctx| {
725            let imp = make_dynamic("./utils", vec![], Some("utils"));
726            let file = Path::new("/project/src/app.ts");
727            let result = resolve_single_dynamic_import(ctx, file, &imp);
728
729            assert_eq!(result.len(), 1);
730            assert!(matches!(
731                result[0].info.imported_name,
732                ImportedName::Namespace
733            ));
734            assert_eq!(result[0].info.local_name, "utils");
735        });
736    }
737
738    #[test]
739    fn dynamic_import_side_effect() {
740        with_empty_ctx(|ctx| {
741            let imp = make_dynamic("./polyfill", vec![], None);
742            let file = Path::new("/project/src/app.ts");
743            let result = resolve_single_dynamic_import(ctx, file, &imp);
744
745            assert_eq!(result.len(), 1);
746            assert!(matches!(
747                result[0].info.imported_name,
748                ImportedName::SideEffect
749            ));
750            assert_eq!(result[0].info.local_name, "");
751            assert_eq!(result[0].info.source, "./polyfill");
752        });
753    }
754
755    #[test]
756    fn dynamic_import_destructured_takes_priority_over_local_name() {
757        // When both destructured_names and local_name are set,
758        // destructured_names wins (checked first).
759        with_empty_ctx(|ctx| {
760            let imp = DynamicImportInfo {
761                source: "./mod".into(),
762                span: dummy_span(),
763                destructured_names: vec!["a".into()],
764                local_name: Some("mod".into()),
765            };
766            let file = Path::new("/project/src/app.ts");
767            let result = resolve_single_dynamic_import(ctx, file, &imp);
768
769            assert_eq!(result.len(), 1);
770            assert!(matches!(
771                result[0].info.imported_name,
772                ImportedName::Named(ref n) if n == "a"
773            ));
774        });
775    }
776
777    // -----------------------------------------------------------------------
778    // resolve_dynamic_imports (batch)
779    // -----------------------------------------------------------------------
780
781    #[test]
782    fn dynamic_imports_flattens_multiple() {
783        with_empty_ctx(|ctx| {
784            let imports = vec![
785                make_dynamic("./a", vec!["x", "y"], None),
786                make_dynamic("./b", vec![], Some("b")),
787                make_dynamic("./c", vec![], None),
788            ];
789            let file = Path::new("/project/src/app.ts");
790            let result = resolve_dynamic_imports(ctx, file, &imports);
791
792            // ./a -> 2 Named, ./b -> 1 Namespace, ./c -> 1 SideEffect = 4 total
793            assert_eq!(result.len(), 4);
794        });
795    }
796
797    #[test]
798    fn dynamic_imports_empty_list() {
799        with_empty_ctx(|ctx| {
800            let file = Path::new("/project/src/app.ts");
801            let result = resolve_dynamic_imports(ctx, file, &[]);
802            assert!(result.is_empty());
803        });
804    }
805
806    // -----------------------------------------------------------------------
807    // resolve_re_exports
808    // -----------------------------------------------------------------------
809
810    #[test]
811    fn re_exports_maps_each_entry() {
812        with_empty_ctx(|ctx| {
813            let re_exports = vec![
814                make_re_export("./utils", "helper", "helper"),
815                make_re_export("./types", "*", "*"),
816            ];
817            let file = Path::new("/project/src/index.ts");
818            let result = resolve_re_exports(ctx, file, &re_exports);
819
820            assert_eq!(result.len(), 2);
821            assert_eq!(result[0].info.source, "./utils");
822            assert_eq!(result[0].info.imported_name, "helper");
823            assert_eq!(result[0].info.exported_name, "helper");
824            assert_eq!(result[1].info.source, "./types");
825            assert_eq!(result[1].info.imported_name, "*");
826        });
827    }
828
829    #[test]
830    fn re_exports_empty_list() {
831        with_empty_ctx(|ctx| {
832            let file = Path::new("/project/src/index.ts");
833            let result = resolve_re_exports(ctx, file, &[]);
834            assert!(result.is_empty());
835        });
836    }
837
838    #[test]
839    fn re_exports_preserves_type_only() {
840        with_empty_ctx(|ctx| {
841            let re_exports = vec![ReExportInfo {
842                source: "./types".into(),
843                imported_name: "MyType".into(),
844                exported_name: "MyType".into(),
845                is_type_only: true,
846            }];
847            let file = Path::new("/project/src/index.ts");
848            let result = resolve_re_exports(ctx, file, &re_exports);
849
850            assert_eq!(result.len(), 1);
851            assert!(result[0].info.is_type_only);
852        });
853    }
854
855    // -----------------------------------------------------------------------
856    // resolve_single_require
857    // -----------------------------------------------------------------------
858
859    #[test]
860    fn require_namespace_without_destructuring() {
861        with_empty_ctx(|ctx| {
862            let req = make_require("fs", vec![], Some("fs"));
863            let file = Path::new("/project/src/app.js");
864            let result = resolve_single_require(ctx, file, &req);
865
866            assert_eq!(result.len(), 1);
867            assert!(matches!(
868                result[0].info.imported_name,
869                ImportedName::Namespace
870            ));
871            assert_eq!(result[0].info.local_name, "fs");
872            assert_eq!(result[0].info.source, "fs");
873        });
874    }
875
876    #[test]
877    fn require_namespace_without_local_name() {
878        with_empty_ctx(|ctx| {
879            let req = make_require("./side-effect", vec![], None);
880            let file = Path::new("/project/src/app.js");
881            let result = resolve_single_require(ctx, file, &req);
882
883            assert_eq!(result.len(), 1);
884            assert!(matches!(
885                result[0].info.imported_name,
886                ImportedName::Namespace
887            ));
888            // No local name -> empty string from unwrap_or_default
889            assert_eq!(result[0].info.local_name, "");
890        });
891    }
892
893    #[test]
894    fn require_with_destructured_names() {
895        with_empty_ctx(|ctx| {
896            let req = make_require("path", vec!["join", "resolve"], None);
897            let file = Path::new("/project/src/app.js");
898            let result = resolve_single_require(ctx, file, &req);
899
900            assert_eq!(result.len(), 2);
901            assert!(matches!(
902                result[0].info.imported_name,
903                ImportedName::Named(ref n) if n == "join"
904            ));
905            assert_eq!(result[0].info.local_name, "join");
906            assert!(matches!(
907                result[1].info.imported_name,
908                ImportedName::Named(ref n) if n == "resolve"
909            ));
910            assert_eq!(result[1].info.local_name, "resolve");
911            // Both share the same source
912            assert_eq!(result[0].info.source, "path");
913            assert_eq!(result[1].info.source, "path");
914        });
915    }
916
917    #[test]
918    fn require_destructured_is_not_type_only() {
919        with_empty_ctx(|ctx| {
920            let req = make_require("path", vec!["join"], None);
921            let file = Path::new("/project/src/app.js");
922            let result = resolve_single_require(ctx, file, &req);
923
924            assert_eq!(result.len(), 1);
925            assert!(!result[0].info.is_type_only);
926        });
927    }
928
929    // -----------------------------------------------------------------------
930    // resolve_require_imports (batch)
931    // -----------------------------------------------------------------------
932
933    #[test]
934    fn require_imports_flattens_multiple() {
935        with_empty_ctx(|ctx| {
936            let reqs = vec![
937                make_require("fs", vec![], Some("fs")),
938                make_require("path", vec!["join", "resolve"], None),
939            ];
940            let file = Path::new("/project/src/app.js");
941            let result = resolve_require_imports(ctx, file, &reqs);
942
943            // fs -> 1 Namespace, path -> 2 Named = 3 total
944            assert_eq!(result.len(), 3);
945        });
946    }
947
948    #[test]
949    fn require_imports_empty_list() {
950        with_empty_ctx(|ctx| {
951            let file = Path::new("/project/src/app.js");
952            let result = resolve_require_imports(ctx, file, &[]);
953            assert!(result.is_empty());
954        });
955    }
956
957    // -----------------------------------------------------------------------
958    // apply_specifier_upgrades
959    // -----------------------------------------------------------------------
960
961    #[test]
962    fn specifier_upgrades_npm_to_internal() {
963        // Module 0 resolves `preact/hooks` to InternalModule(FileId(5))
964        // Module 1 resolves `preact/hooks` to NpmPackage("preact")
965        // After upgrade, module 1 should also point to InternalModule(FileId(5))
966        let mut modules = vec![
967            make_resolved_module(
968                0,
969                vec![make_resolved_import(
970                    "preact/hooks",
971                    ResolveResult::InternalModule(FileId(5)),
972                )],
973                vec![],
974                vec![],
975            ),
976            make_resolved_module(
977                1,
978                vec![make_resolved_import(
979                    "preact/hooks",
980                    ResolveResult::NpmPackage("preact".into()),
981                )],
982                vec![],
983                vec![],
984            ),
985        ];
986
987        apply_specifier_upgrades(&mut modules);
988
989        assert!(matches!(
990            modules[1].resolved_imports[0].target,
991            ResolveResult::InternalModule(FileId(5))
992        ));
993    }
994
995    #[test]
996    fn specifier_upgrades_noop_when_no_internal() {
997        // All modules resolve `lodash` to NpmPackage — no upgrade should happen
998        let mut modules = vec![
999            make_resolved_module(
1000                0,
1001                vec![make_resolved_import(
1002                    "lodash",
1003                    ResolveResult::NpmPackage("lodash".into()),
1004                )],
1005                vec![],
1006                vec![],
1007            ),
1008            make_resolved_module(
1009                1,
1010                vec![make_resolved_import(
1011                    "lodash",
1012                    ResolveResult::NpmPackage("lodash".into()),
1013                )],
1014                vec![],
1015                vec![],
1016            ),
1017        ];
1018
1019        apply_specifier_upgrades(&mut modules);
1020
1021        assert!(matches!(
1022            modules[0].resolved_imports[0].target,
1023            ResolveResult::NpmPackage(_)
1024        ));
1025        assert!(matches!(
1026            modules[1].resolved_imports[0].target,
1027            ResolveResult::NpmPackage(_)
1028        ));
1029    }
1030
1031    #[test]
1032    fn specifier_upgrades_empty_modules() {
1033        let mut modules: Vec<ResolvedModule> = vec![];
1034        apply_specifier_upgrades(&mut modules);
1035        assert!(modules.is_empty());
1036    }
1037
1038    #[test]
1039    fn specifier_upgrades_skips_relative_specifiers() {
1040        // Relative specifiers (./foo) are NOT bare specifiers, so they should
1041        // never be candidates for upgrade.
1042        let mut modules = vec![
1043            make_resolved_module(
1044                0,
1045                vec![make_resolved_import(
1046                    "./utils",
1047                    ResolveResult::InternalModule(FileId(5)),
1048                )],
1049                vec![],
1050                vec![],
1051            ),
1052            make_resolved_module(
1053                1,
1054                vec![make_resolved_import(
1055                    "./utils",
1056                    ResolveResult::NpmPackage("utils".into()),
1057                )],
1058                vec![],
1059                vec![],
1060            ),
1061        ];
1062
1063        apply_specifier_upgrades(&mut modules);
1064
1065        // Module 1 should still be NpmPackage — relative specifier not upgraded
1066        assert!(matches!(
1067            modules[1].resolved_imports[0].target,
1068            ResolveResult::NpmPackage(_)
1069        ));
1070    }
1071
1072    #[test]
1073    fn specifier_upgrades_applies_to_dynamic_imports() {
1074        let mut modules = vec![
1075            make_resolved_module(
1076                0,
1077                vec![],
1078                vec![make_resolved_import(
1079                    "preact/hooks",
1080                    ResolveResult::InternalModule(FileId(5)),
1081                )],
1082                vec![],
1083            ),
1084            make_resolved_module(
1085                1,
1086                vec![],
1087                vec![make_resolved_import(
1088                    "preact/hooks",
1089                    ResolveResult::NpmPackage("preact".into()),
1090                )],
1091                vec![],
1092            ),
1093        ];
1094
1095        apply_specifier_upgrades(&mut modules);
1096
1097        assert!(matches!(
1098            modules[1].resolved_dynamic_imports[0].target,
1099            ResolveResult::InternalModule(FileId(5))
1100        ));
1101    }
1102
1103    #[test]
1104    fn specifier_upgrades_applies_to_re_exports() {
1105        let mut modules = vec![
1106            make_resolved_module(
1107                0,
1108                vec![],
1109                vec![],
1110                vec![make_resolved_re_export(
1111                    "preact/hooks",
1112                    ResolveResult::InternalModule(FileId(5)),
1113                )],
1114            ),
1115            make_resolved_module(
1116                1,
1117                vec![],
1118                vec![],
1119                vec![make_resolved_re_export(
1120                    "preact/hooks",
1121                    ResolveResult::NpmPackage("preact".into()),
1122                )],
1123            ),
1124        ];
1125
1126        apply_specifier_upgrades(&mut modules);
1127
1128        assert!(matches!(
1129            modules[1].re_exports[0].target,
1130            ResolveResult::InternalModule(FileId(5))
1131        ));
1132    }
1133
1134    #[test]
1135    fn specifier_upgrades_does_not_downgrade_internal() {
1136        // If both modules already resolve to InternalModule, nothing changes
1137        let mut modules = vec![
1138            make_resolved_module(
1139                0,
1140                vec![make_resolved_import(
1141                    "preact/hooks",
1142                    ResolveResult::InternalModule(FileId(5)),
1143                )],
1144                vec![],
1145                vec![],
1146            ),
1147            make_resolved_module(
1148                1,
1149                vec![make_resolved_import(
1150                    "preact/hooks",
1151                    ResolveResult::InternalModule(FileId(5)),
1152                )],
1153                vec![],
1154                vec![],
1155            ),
1156        ];
1157
1158        apply_specifier_upgrades(&mut modules);
1159
1160        assert!(matches!(
1161            modules[0].resolved_imports[0].target,
1162            ResolveResult::InternalModule(FileId(5))
1163        ));
1164        assert!(matches!(
1165            modules[1].resolved_imports[0].target,
1166            ResolveResult::InternalModule(FileId(5))
1167        ));
1168    }
1169
1170    #[test]
1171    fn specifier_upgrades_first_internal_wins() {
1172        // Two modules resolve the same bare specifier to different internal files.
1173        // The first one (by module order) wins.
1174        let mut modules = vec![
1175            make_resolved_module(
1176                0,
1177                vec![make_resolved_import(
1178                    "shared-lib",
1179                    ResolveResult::InternalModule(FileId(10)),
1180                )],
1181                vec![],
1182                vec![],
1183            ),
1184            make_resolved_module(
1185                1,
1186                vec![make_resolved_import(
1187                    "shared-lib",
1188                    ResolveResult::InternalModule(FileId(20)),
1189                )],
1190                vec![],
1191                vec![],
1192            ),
1193            make_resolved_module(
1194                2,
1195                vec![make_resolved_import(
1196                    "shared-lib",
1197                    ResolveResult::NpmPackage("shared-lib".into()),
1198                )],
1199                vec![],
1200                vec![],
1201            ),
1202        ];
1203
1204        apply_specifier_upgrades(&mut modules);
1205
1206        // Module 2 should be upgraded to the first FileId encountered (10)
1207        assert!(matches!(
1208            modules[2].resolved_imports[0].target,
1209            ResolveResult::InternalModule(FileId(10))
1210        ));
1211    }
1212
1213    #[test]
1214    fn specifier_upgrades_does_not_touch_unresolvable() {
1215        // Unresolvable should not be upgraded even if a bare specifier
1216        // matches an InternalModule elsewhere.
1217        let mut modules = vec![
1218            make_resolved_module(
1219                0,
1220                vec![make_resolved_import(
1221                    "my-lib",
1222                    ResolveResult::InternalModule(FileId(1)),
1223                )],
1224                vec![],
1225                vec![],
1226            ),
1227            make_resolved_module(
1228                1,
1229                vec![ResolvedImport {
1230                    info: make_import("my-lib", ImportedName::Default, "myLib"),
1231                    target: ResolveResult::Unresolvable("my-lib".into()),
1232                }],
1233                vec![],
1234                vec![],
1235            ),
1236        ];
1237
1238        apply_specifier_upgrades(&mut modules);
1239
1240        // Unresolvable should remain unresolvable
1241        assert!(matches!(
1242            modules[1].resolved_imports[0].target,
1243            ResolveResult::Unresolvable(_)
1244        ));
1245    }
1246
1247    #[test]
1248    fn specifier_upgrades_cross_import_and_re_export() {
1249        // An import in module 0 resolves to InternalModule, a re-export in
1250        // module 1 for the same specifier should also be upgraded.
1251        let mut modules = vec![
1252            make_resolved_module(
1253                0,
1254                vec![make_resolved_import(
1255                    "@myorg/utils",
1256                    ResolveResult::InternalModule(FileId(3)),
1257                )],
1258                vec![],
1259                vec![],
1260            ),
1261            make_resolved_module(
1262                1,
1263                vec![],
1264                vec![],
1265                vec![make_resolved_re_export(
1266                    "@myorg/utils",
1267                    ResolveResult::NpmPackage("@myorg/utils".into()),
1268                )],
1269            ),
1270        ];
1271
1272        apply_specifier_upgrades(&mut modules);
1273
1274        assert!(matches!(
1275            modules[1].re_exports[0].target,
1276            ResolveResult::InternalModule(FileId(3))
1277        ));
1278    }
1279
1280    // -----------------------------------------------------------------------
1281    // resolve_dynamic_patterns
1282    // -----------------------------------------------------------------------
1283
1284    #[test]
1285    fn dynamic_patterns_matches_files_in_dir() {
1286        let from_dir = Path::new("/project/src");
1287        let patterns = vec![DynamicImportPattern {
1288            prefix: "./locales/".into(),
1289            suffix: Some(".json".into()),
1290            span: dummy_span(),
1291        }];
1292        let canonical_paths = vec![
1293            PathBuf::from("/project/src/locales/en.json"),
1294            PathBuf::from("/project/src/locales/fr.json"),
1295            PathBuf::from("/project/src/utils.ts"),
1296        ];
1297        let files = vec![
1298            DiscoveredFile {
1299                id: FileId(0),
1300                path: PathBuf::from("/project/src/locales/en.json"),
1301                size_bytes: 100,
1302            },
1303            DiscoveredFile {
1304                id: FileId(1),
1305                path: PathBuf::from("/project/src/locales/fr.json"),
1306                size_bytes: 100,
1307            },
1308            DiscoveredFile {
1309                id: FileId(2),
1310                path: PathBuf::from("/project/src/utils.ts"),
1311                size_bytes: 100,
1312            },
1313        ];
1314
1315        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1316
1317        assert_eq!(result.len(), 1);
1318        assert_eq!(result[0].1.len(), 2);
1319        assert!(result[0].1.contains(&FileId(0)));
1320        assert!(result[0].1.contains(&FileId(1)));
1321    }
1322
1323    #[test]
1324    fn dynamic_patterns_no_matches_returns_empty() {
1325        let from_dir = Path::new("/project/src");
1326        let patterns = vec![DynamicImportPattern {
1327            prefix: "./locales/".into(),
1328            suffix: Some(".json".into()),
1329            span: dummy_span(),
1330        }];
1331        let canonical_paths = vec![PathBuf::from("/project/src/utils.ts")];
1332        let files = vec![DiscoveredFile {
1333            id: FileId(0),
1334            path: PathBuf::from("/project/src/utils.ts"),
1335            size_bytes: 100,
1336        }];
1337
1338        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1339
1340        assert!(result.is_empty());
1341    }
1342
1343    #[test]
1344    fn dynamic_patterns_empty_patterns_list() {
1345        let from_dir = Path::new("/project/src");
1346        let canonical_paths = vec![PathBuf::from("/project/src/utils.ts")];
1347        let files = vec![DiscoveredFile {
1348            id: FileId(0),
1349            path: PathBuf::from("/project/src/utils.ts"),
1350            size_bytes: 100,
1351        }];
1352
1353        let result = resolve_dynamic_patterns(from_dir, &[], &canonical_paths, &files);
1354        assert!(result.is_empty());
1355    }
1356
1357    #[test]
1358    fn dynamic_patterns_glob_prefix_passthrough() {
1359        let from_dir = Path::new("/project/src");
1360        let patterns = vec![DynamicImportPattern {
1361            prefix: "./**/*.ts".into(),
1362            suffix: None,
1363            span: dummy_span(),
1364        }];
1365        let canonical_paths = vec![
1366            PathBuf::from("/project/src/utils.ts"),
1367            PathBuf::from("/project/src/deep/nested.ts"),
1368        ];
1369        let files = vec![
1370            DiscoveredFile {
1371                id: FileId(0),
1372                path: PathBuf::from("/project/src/utils.ts"),
1373                size_bytes: 100,
1374            },
1375            DiscoveredFile {
1376                id: FileId(1),
1377                path: PathBuf::from("/project/src/deep/nested.ts"),
1378                size_bytes: 100,
1379            },
1380        ];
1381
1382        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1383
1384        assert_eq!(result.len(), 1);
1385        assert_eq!(result[0].1.len(), 2);
1386    }
1387
1388    // -----------------------------------------------------------------------
1389    // Unresolvable specifier handling
1390    // -----------------------------------------------------------------------
1391
1392    #[test]
1393    fn static_import_unresolvable_relative_path() {
1394        with_empty_ctx(|ctx| {
1395            let imports = vec![make_import(
1396                "./nonexistent",
1397                ImportedName::Default,
1398                "missing",
1399            )];
1400            let file = Path::new("/project/src/app.ts");
1401            let result = resolve_static_imports(ctx, file, &imports);
1402
1403            assert_eq!(result.len(), 1);
1404            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1405        });
1406    }
1407
1408    #[test]
1409    fn static_import_bare_specifier_becomes_npm_package() {
1410        with_empty_ctx(|ctx| {
1411            let imports = vec![make_import("react", ImportedName::Default, "React")];
1412            let file = Path::new("/project/src/app.ts");
1413            let result = resolve_static_imports(ctx, file, &imports);
1414
1415            assert_eq!(result.len(), 1);
1416            assert!(matches!(
1417                result[0].target,
1418                ResolveResult::NpmPackage(ref pkg) if pkg == "react"
1419            ));
1420        });
1421    }
1422
1423    #[test]
1424    fn require_bare_specifier_becomes_npm_package() {
1425        with_empty_ctx(|ctx| {
1426            let req = make_require("express", vec![], Some("express"));
1427            let file = Path::new("/project/src/app.js");
1428            let result = resolve_single_require(ctx, file, &req);
1429
1430            assert_eq!(result.len(), 1);
1431            assert!(matches!(
1432                result[0].target,
1433                ResolveResult::NpmPackage(ref pkg) if pkg == "express"
1434            ));
1435        });
1436    }
1437
1438    #[test]
1439    fn dynamic_import_unresolvable() {
1440        with_empty_ctx(|ctx| {
1441            let imp = make_dynamic("./missing-module", vec![], None);
1442            let file = Path::new("/project/src/app.ts");
1443            let result = resolve_single_dynamic_import(ctx, file, &imp);
1444
1445            assert_eq!(result.len(), 1);
1446            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1447        });
1448    }
1449
1450    #[test]
1451    fn re_export_unresolvable() {
1452        with_empty_ctx(|ctx| {
1453            let re_exports = vec![make_re_export("./missing", "foo", "foo")];
1454            let file = Path::new("/project/src/index.ts");
1455            let result = resolve_re_exports(ctx, file, &re_exports);
1456
1457            assert_eq!(result.len(), 1);
1458            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1459        });
1460    }
1461
1462    // -----------------------------------------------------------------------
1463    // Dynamic import pattern resolution (template literals & concat)
1464    // -----------------------------------------------------------------------
1465
1466    #[test]
1467    fn dynamic_patterns_template_literal_prefix_suffix() {
1468        // Simulates `import(`./locales/${lang}.json`)` -> prefix="./locales/", suffix=".json"
1469        let from_dir = Path::new("/project/src");
1470        let patterns = vec![DynamicImportPattern {
1471            prefix: "./locales/".into(),
1472            suffix: Some(".json".into()),
1473            span: dummy_span(),
1474        }];
1475        let canonical_paths = vec![
1476            PathBuf::from("/project/src/locales/en.json"),
1477            PathBuf::from("/project/src/locales/de.json"),
1478            PathBuf::from("/project/src/locales/README.md"),
1479            PathBuf::from("/project/src/config.ts"),
1480        ];
1481        let files = vec![
1482            DiscoveredFile {
1483                id: FileId(0),
1484                path: PathBuf::from("/project/src/locales/en.json"),
1485                size_bytes: 100,
1486            },
1487            DiscoveredFile {
1488                id: FileId(1),
1489                path: PathBuf::from("/project/src/locales/de.json"),
1490                size_bytes: 100,
1491            },
1492            DiscoveredFile {
1493                id: FileId(2),
1494                path: PathBuf::from("/project/src/locales/README.md"),
1495                size_bytes: 100,
1496            },
1497            DiscoveredFile {
1498                id: FileId(3),
1499                path: PathBuf::from("/project/src/config.ts"),
1500                size_bytes: 100,
1501            },
1502        ];
1503
1504        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1505
1506        assert_eq!(result.len(), 1, "should produce exactly one pattern match");
1507        let matched_ids = &result[0].1;
1508        assert_eq!(
1509            matched_ids.len(),
1510            2,
1511            "should match en.json and de.json only"
1512        );
1513        assert!(matched_ids.contains(&FileId(0)));
1514        assert!(matched_ids.contains(&FileId(1)));
1515        assert!(
1516            !matched_ids.contains(&FileId(2)),
1517            "README.md should not match .json suffix"
1518        );
1519        assert!(
1520            !matched_ids.contains(&FileId(3)),
1521            "config.ts should not match locales/ prefix"
1522        );
1523    }
1524
1525    #[test]
1526    fn dynamic_patterns_string_concat_prefix_only() {
1527        // Simulates `import('./pages/' + name)` -> prefix="./pages/", suffix=None
1528        let from_dir = Path::new("/project/src");
1529        let patterns = vec![DynamicImportPattern {
1530            prefix: "./pages/".into(),
1531            suffix: None,
1532            span: dummy_span(),
1533        }];
1534        let canonical_paths = vec![
1535            PathBuf::from("/project/src/pages/home.ts"),
1536            PathBuf::from("/project/src/pages/about.ts"),
1537            PathBuf::from("/project/src/pages/nested/deep.ts"),
1538            PathBuf::from("/project/src/utils.ts"),
1539        ];
1540        let files = vec![
1541            DiscoveredFile {
1542                id: FileId(0),
1543                path: PathBuf::from("/project/src/pages/home.ts"),
1544                size_bytes: 100,
1545            },
1546            DiscoveredFile {
1547                id: FileId(1),
1548                path: PathBuf::from("/project/src/pages/about.ts"),
1549                size_bytes: 100,
1550            },
1551            DiscoveredFile {
1552                id: FileId(2),
1553                path: PathBuf::from("/project/src/pages/nested/deep.ts"),
1554                size_bytes: 100,
1555            },
1556            DiscoveredFile {
1557                id: FileId(3),
1558                path: PathBuf::from("/project/src/utils.ts"),
1559                size_bytes: 100,
1560            },
1561        ];
1562
1563        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1564
1565        assert_eq!(result.len(), 1);
1566        let matched_ids = &result[0].1;
1567        // ./pages/* matches files directly in pages/ (globset * does not cross /)
1568        assert!(matched_ids.contains(&FileId(0)), "home.ts should match");
1569        assert!(matched_ids.contains(&FileId(1)), "about.ts should match");
1570        assert!(
1571            !matched_ids.contains(&FileId(3)),
1572            "utils.ts should not match pages/ prefix"
1573        );
1574    }
1575
1576    #[test]
1577    fn dynamic_patterns_import_meta_glob_recursive() {
1578        // Simulates `import.meta.glob('./components/**/*.ts')` -> prefix has glob chars
1579        let from_dir = Path::new("/project/src");
1580        let patterns = vec![DynamicImportPattern {
1581            prefix: "./components/**/*.ts".into(),
1582            suffix: None,
1583            span: dummy_span(),
1584        }];
1585        let canonical_paths = vec![
1586            PathBuf::from("/project/src/components/Button.ts"),
1587            PathBuf::from("/project/src/components/forms/Input.ts"),
1588            PathBuf::from("/project/src/components/Button.css"),
1589            PathBuf::from("/project/src/utils.ts"),
1590        ];
1591        let files = vec![
1592            DiscoveredFile {
1593                id: FileId(0),
1594                path: PathBuf::from("/project/src/components/Button.ts"),
1595                size_bytes: 100,
1596            },
1597            DiscoveredFile {
1598                id: FileId(1),
1599                path: PathBuf::from("/project/src/components/forms/Input.ts"),
1600                size_bytes: 100,
1601            },
1602            DiscoveredFile {
1603                id: FileId(2),
1604                path: PathBuf::from("/project/src/components/Button.css"),
1605                size_bytes: 100,
1606            },
1607            DiscoveredFile {
1608                id: FileId(3),
1609                path: PathBuf::from("/project/src/utils.ts"),
1610                size_bytes: 100,
1611            },
1612        ];
1613
1614        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1615
1616        assert_eq!(result.len(), 1);
1617        let matched_ids = &result[0].1;
1618        assert!(
1619            matched_ids.contains(&FileId(0)),
1620            "Button.ts should match **/*.ts"
1621        );
1622        assert!(
1623            matched_ids.contains(&FileId(1)),
1624            "forms/Input.ts should match **/*.ts recursively"
1625        );
1626        assert!(
1627            !matched_ids.contains(&FileId(2)),
1628            "Button.css should not match *.ts pattern"
1629        );
1630        assert!(
1631            !matched_ids.contains(&FileId(3)),
1632            "utils.ts outside components/ should not match"
1633        );
1634    }
1635
1636    #[test]
1637    fn dynamic_patterns_import_meta_glob_brace_expansion() {
1638        // Simulates `import.meta.glob('./routes/**/*.{ts,tsx}')`
1639        let from_dir = Path::new("/project/src");
1640        let patterns = vec![DynamicImportPattern {
1641            prefix: "./routes/**/*.{ts,tsx}".into(),
1642            suffix: None,
1643            span: dummy_span(),
1644        }];
1645        let canonical_paths = vec![
1646            PathBuf::from("/project/src/routes/home.ts"),
1647            PathBuf::from("/project/src/routes/about.tsx"),
1648            PathBuf::from("/project/src/routes/layout.css"),
1649        ];
1650        let files = vec![
1651            DiscoveredFile {
1652                id: FileId(0),
1653                path: PathBuf::from("/project/src/routes/home.ts"),
1654                size_bytes: 100,
1655            },
1656            DiscoveredFile {
1657                id: FileId(1),
1658                path: PathBuf::from("/project/src/routes/about.tsx"),
1659                size_bytes: 100,
1660            },
1661            DiscoveredFile {
1662                id: FileId(2),
1663                path: PathBuf::from("/project/src/routes/layout.css"),
1664                size_bytes: 100,
1665            },
1666        ];
1667
1668        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1669
1670        assert_eq!(result.len(), 1);
1671        let matched_ids = &result[0].1;
1672        assert!(matched_ids.contains(&FileId(0)), "home.ts should match");
1673        assert!(matched_ids.contains(&FileId(1)), "about.tsx should match");
1674        assert!(
1675            !matched_ids.contains(&FileId(2)),
1676            "layout.css should not match ts/tsx brace expansion"
1677        );
1678    }
1679
1680    #[test]
1681    fn dynamic_patterns_no_static_part_matches_everything() {
1682        // Simulates `import(variable)` where there is no static prefix at all.
1683        // In practice, the extractor would not emit a DynamicImportPattern for
1684        // a fully dynamic import, but if it did with prefix="" and suffix=None,
1685        // the glob would be "*" which matches everything in the directory.
1686        let from_dir = Path::new("/project/src");
1687        let patterns = vec![DynamicImportPattern {
1688            prefix: String::new(),
1689            suffix: None,
1690            span: dummy_span(),
1691        }];
1692        let canonical_paths = vec![
1693            PathBuf::from("/project/src/a.ts"),
1694            PathBuf::from("/project/src/b.ts"),
1695        ];
1696        let files = vec![
1697            DiscoveredFile {
1698                id: FileId(0),
1699                path: PathBuf::from("/project/src/a.ts"),
1700                size_bytes: 100,
1701            },
1702            DiscoveredFile {
1703                id: FileId(1),
1704                path: PathBuf::from("/project/src/b.ts"),
1705                size_bytes: 100,
1706            },
1707        ];
1708
1709        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1710
1711        // "*" matches all files directly in from_dir
1712        assert_eq!(result.len(), 1);
1713        assert_eq!(result[0].1.len(), 2);
1714    }
1715
1716    #[test]
1717    fn dynamic_patterns_multiple_patterns_independent() {
1718        // Multiple patterns should be matched independently
1719        let from_dir = Path::new("/project/src");
1720        let patterns = vec![
1721            DynamicImportPattern {
1722                prefix: "./locales/".into(),
1723                suffix: Some(".json".into()),
1724                span: dummy_span(),
1725            },
1726            DynamicImportPattern {
1727                prefix: "./pages/".into(),
1728                suffix: Some(".ts".into()),
1729                span: dummy_span(),
1730            },
1731        ];
1732        let canonical_paths = vec![
1733            PathBuf::from("/project/src/locales/en.json"),
1734            PathBuf::from("/project/src/pages/home.ts"),
1735            PathBuf::from("/project/src/utils.ts"),
1736        ];
1737        let files = vec![
1738            DiscoveredFile {
1739                id: FileId(0),
1740                path: PathBuf::from("/project/src/locales/en.json"),
1741                size_bytes: 100,
1742            },
1743            DiscoveredFile {
1744                id: FileId(1),
1745                path: PathBuf::from("/project/src/pages/home.ts"),
1746                size_bytes: 100,
1747            },
1748            DiscoveredFile {
1749                id: FileId(2),
1750                path: PathBuf::from("/project/src/utils.ts"),
1751                size_bytes: 100,
1752            },
1753        ];
1754
1755        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1756
1757        assert_eq!(result.len(), 2, "both patterns should produce matches");
1758        // First pattern matches locales
1759        assert!(result[0].1.contains(&FileId(0)));
1760        assert_eq!(result[0].1.len(), 1);
1761        // Second pattern matches pages
1762        assert!(result[1].1.contains(&FileId(1)));
1763        assert_eq!(result[1].1.len(), 1);
1764    }
1765
1766    #[test]
1767    fn dynamic_patterns_files_outside_from_dir_not_matched() {
1768        // Files not under from_dir should never match
1769        let from_dir = Path::new("/project/src");
1770        let patterns = vec![DynamicImportPattern {
1771            prefix: "./utils/".into(),
1772            suffix: None,
1773            span: dummy_span(),
1774        }];
1775        let canonical_paths = vec![
1776            PathBuf::from("/project/other/utils/helper.ts"),
1777            PathBuf::from("/project/src/utils/helper.ts"),
1778        ];
1779        let files = vec![
1780            DiscoveredFile {
1781                id: FileId(0),
1782                path: PathBuf::from("/project/other/utils/helper.ts"),
1783                size_bytes: 100,
1784            },
1785            DiscoveredFile {
1786                id: FileId(1),
1787                path: PathBuf::from("/project/src/utils/helper.ts"),
1788                size_bytes: 100,
1789            },
1790        ];
1791
1792        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1793
1794        assert_eq!(result.len(), 1);
1795        let matched_ids = &result[0].1;
1796        assert!(
1797            !matched_ids.contains(&FileId(0)),
1798            "file outside from_dir should not match"
1799        );
1800        assert!(
1801            matched_ids.contains(&FileId(1)),
1802            "file inside from_dir should match"
1803        );
1804    }
1805
1806    // -----------------------------------------------------------------------
1807    // Dynamic patterns with empty canonical paths (root-is-canonical path)
1808    // -----------------------------------------------------------------------
1809
1810    #[test]
1811    fn dynamic_patterns_raw_paths_when_canonical_empty() {
1812        // When canonical_paths is empty, resolve_dynamic_patterns uses raw file paths
1813        let from_dir = Path::new("/project/src");
1814        let patterns = vec![DynamicImportPattern {
1815            prefix: "./locales/".into(),
1816            suffix: Some(".json".into()),
1817            span: dummy_span(),
1818        }];
1819        let canonical_paths: Vec<PathBuf> = vec![]; // empty = root is canonical
1820        let files = vec![
1821            DiscoveredFile {
1822                id: FileId(0),
1823                path: PathBuf::from("/project/src/locales/en.json"),
1824                size_bytes: 100,
1825            },
1826            DiscoveredFile {
1827                id: FileId(1),
1828                path: PathBuf::from("/project/src/locales/fr.json"),
1829                size_bytes: 100,
1830            },
1831            DiscoveredFile {
1832                id: FileId(2),
1833                path: PathBuf::from("/project/src/main.ts"),
1834                size_bytes: 100,
1835            },
1836        ];
1837
1838        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1839
1840        assert_eq!(result.len(), 1);
1841        assert_eq!(result[0].1.len(), 2);
1842        assert!(result[0].1.contains(&FileId(0)));
1843        assert!(result[0].1.contains(&FileId(1)));
1844    }
1845
1846    #[test]
1847    fn dynamic_patterns_raw_paths_no_match() {
1848        let from_dir = Path::new("/project/src");
1849        let patterns = vec![DynamicImportPattern {
1850            prefix: "./missing-dir/".into(),
1851            suffix: None,
1852            span: dummy_span(),
1853        }];
1854        let canonical_paths: Vec<PathBuf> = vec![];
1855        let files = vec![DiscoveredFile {
1856            id: FileId(0),
1857            path: PathBuf::from("/project/src/utils.ts"),
1858            size_bytes: 100,
1859        }];
1860
1861        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1862
1863        assert!(
1864            result.is_empty(),
1865            "no files match the pattern, should return empty"
1866        );
1867    }
1868
1869    // -----------------------------------------------------------------------
1870    // Dynamic import: destructured names preserve source across all entries
1871    // -----------------------------------------------------------------------
1872
1873    #[test]
1874    fn dynamic_import_destructured_all_share_same_target() {
1875        with_empty_ctx(|ctx| {
1876            let imp = make_dynamic("react", vec!["useState", "useEffect", "useRef"], None);
1877            let file = Path::new("/project/src/app.ts");
1878            let result = resolve_single_dynamic_import(ctx, file, &imp);
1879
1880            assert_eq!(result.len(), 3);
1881            // All three should resolve to the same target (same source)
1882            for resolved in &result {
1883                assert_eq!(resolved.info.source, "react");
1884                assert!(!resolved.info.is_type_only);
1885                assert!(matches!(
1886                    resolved.info.imported_name,
1887                    ImportedName::Named(_)
1888                ));
1889            }
1890            // Verify specific names
1891            let names: Vec<&str> = result
1892                .iter()
1893                .filter_map(|r| match &r.info.imported_name {
1894                    ImportedName::Named(n) => Some(n.as_str()),
1895                    _ => None,
1896                })
1897                .collect();
1898            assert_eq!(names, vec!["useState", "useEffect", "useRef"]);
1899        });
1900    }
1901
1902    #[test]
1903    fn dynamic_import_empty_destructured_with_no_local_is_side_effect() {
1904        // Empty destructured + no local_name = side-effect import
1905        with_empty_ctx(|ctx| {
1906            let imp = make_dynamic("./setup", vec![], None);
1907            let file = Path::new("/project/src/main.ts");
1908            let result = resolve_single_dynamic_import(ctx, file, &imp);
1909
1910            assert_eq!(result.len(), 1);
1911            assert!(matches!(
1912                result[0].info.imported_name,
1913                ImportedName::SideEffect
1914            ));
1915            assert_eq!(result[0].info.local_name, "");
1916        });
1917    }
1918
1919    #[test]
1920    fn dynamic_import_bare_specifier_becomes_npm_package() {
1921        with_empty_ctx(|ctx| {
1922            let imp = make_dynamic("lodash", vec![], Some("_"));
1923            let file = Path::new("/project/src/app.ts");
1924            let result = resolve_single_dynamic_import(ctx, file, &imp);
1925
1926            assert_eq!(result.len(), 1);
1927            assert!(matches!(
1928                result[0].target,
1929                ResolveResult::NpmPackage(ref pkg) if pkg == "lodash"
1930            ));
1931            assert!(matches!(
1932                result[0].info.imported_name,
1933                ImportedName::Namespace
1934            ));
1935        });
1936    }
1937
1938    // -----------------------------------------------------------------------
1939    // Dynamic patterns: invalid glob pattern handling
1940    // -----------------------------------------------------------------------
1941
1942    #[test]
1943    fn dynamic_patterns_invalid_glob_skipped() {
1944        // If a pattern produces an invalid glob, it should be silently skipped
1945        let from_dir = Path::new("/project/src");
1946        let patterns = vec![DynamicImportPattern {
1947            prefix: "./[invalid".into(), // unclosed bracket = invalid glob
1948            suffix: None,
1949            span: dummy_span(),
1950        }];
1951        let canonical_paths = vec![PathBuf::from("/project/src/test.ts")];
1952        let files = vec![DiscoveredFile {
1953            id: FileId(0),
1954            path: PathBuf::from("/project/src/test.ts"),
1955            size_bytes: 100,
1956        }];
1957
1958        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1959
1960        // Invalid glob should be silently dropped (filter_map returns None)
1961        assert!(
1962            result.is_empty(),
1963            "invalid glob pattern should be skipped gracefully"
1964        );
1965    }
1966}