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    // Pre-compute canonical paths ONCE for all files in parallel (avoiding repeated syscalls).
58    // Each canonicalize() is a syscall — parallelizing over rayon reduces wall time.
59    let canonical_paths: Vec<PathBuf> = files
60        .par_iter()
61        .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
62        .collect();
63
64    // Build path -> FileId index using pre-computed canonical paths
65    let path_to_id: FxHashMap<&Path, FileId> = canonical_paths
66        .iter()
67        .enumerate()
68        .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
69        .collect();
70
71    // Also index by non-canonical path for fallback lookups
72    let raw_path_to_id: FxHashMap<&Path, FileId> =
73        files.iter().map(|f| (f.path.as_path(), f.id)).collect();
74
75    // FileIds are sequential 0..n, so direct array indexing is faster than FxHashMap.
76    let file_paths: Vec<&Path> = files.iter().map(|f| f.path.as_path()).collect();
77
78    // Create resolver ONCE and share across threads (oxc_resolver::Resolver is Send + Sync)
79    let resolver = create_resolver(active_plugins);
80
81    // Shared resolution context — avoids passing 6 arguments to every resolve_specifier call
82    let ctx = ResolveContext {
83        resolver: &resolver,
84        path_to_id: &path_to_id,
85        raw_path_to_id: &raw_path_to_id,
86        workspace_roots: &workspace_roots,
87        path_aliases,
88        root,
89    };
90
91    // Resolve in parallel — shared resolver instance.
92    // Each file resolves its own imports independently (no shared bare specifier cache).
93    // oxc_resolver's internal caches (package.json, tsconfig, directory entries) are
94    // shared across threads for performance.
95    let mut resolved: Vec<ResolvedModule> = modules
96        .par_iter()
97        .filter_map(|module| {
98            let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
99                tracing::warn!(
100                    file_id = module.file_id.0,
101                    "Skipping module with unknown file_id during resolution"
102                );
103                return None;
104            };
105
106            let mut all_imports = resolve_static_imports(&ctx, file_path, &module.imports);
107            all_imports.extend(resolve_require_imports(
108                &ctx,
109                file_path,
110                &module.require_calls,
111            ));
112
113            let from_dir = canonical_paths
114                .get(module.file_id.0 as usize)
115                .and_then(|p| p.parent())
116                .unwrap_or(file_path);
117
118            Some(ResolvedModule {
119                file_id: module.file_id,
120                path: file_path.to_path_buf(),
121                exports: module.exports.clone(),
122                re_exports: resolve_re_exports(&ctx, file_path, &module.re_exports),
123                resolved_imports: all_imports,
124                resolved_dynamic_imports: resolve_dynamic_imports(
125                    &ctx,
126                    file_path,
127                    &module.dynamic_imports,
128                ),
129                resolved_dynamic_patterns: resolve_dynamic_patterns(
130                    from_dir,
131                    &module.dynamic_import_patterns,
132                    &canonical_paths,
133                    files,
134                ),
135                member_accesses: module.member_accesses.clone(),
136                whole_object_uses: module.whole_object_uses.clone(),
137                has_cjs_exports: module.has_cjs_exports,
138                unused_import_bindings: module.unused_import_bindings.clone(),
139            })
140        })
141        .collect();
142
143    apply_specifier_upgrades(&mut resolved);
144
145    resolved
146}
147
148/// Resolve standard ES module imports (`import x from './y'`).
149fn resolve_static_imports(
150    ctx: &ResolveContext,
151    file_path: &Path,
152    imports: &[ImportInfo],
153) -> Vec<ResolvedImport> {
154    imports
155        .iter()
156        .map(|imp| ResolvedImport {
157            info: imp.clone(),
158            target: resolve_specifier(ctx, file_path, &imp.source),
159        })
160        .collect()
161}
162
163/// Resolve dynamic `import()` calls, expanding destructured names into individual imports.
164fn resolve_dynamic_imports(
165    ctx: &ResolveContext,
166    file_path: &Path,
167    dynamic_imports: &[DynamicImportInfo],
168) -> Vec<ResolvedImport> {
169    dynamic_imports
170        .iter()
171        .flat_map(|imp| resolve_single_dynamic_import(ctx, file_path, imp))
172        .collect()
173}
174
175/// Convert a single dynamic import into one or more `ResolvedImport` entries.
176fn resolve_single_dynamic_import(
177    ctx: &ResolveContext,
178    file_path: &Path,
179    imp: &DynamicImportInfo,
180) -> Vec<ResolvedImport> {
181    let target = resolve_specifier(ctx, file_path, &imp.source);
182
183    if !imp.destructured_names.is_empty() {
184        // `const { a, b } = await import('./x')` -> Named imports
185        return imp
186            .destructured_names
187            .iter()
188            .map(|name| ResolvedImport {
189                info: ImportInfo {
190                    source: imp.source.clone(),
191                    imported_name: ImportedName::Named(name.clone()),
192                    local_name: name.clone(),
193                    is_type_only: false,
194                    span: imp.span,
195                    source_span: Span::default(),
196                },
197                target: target.clone(),
198            })
199            .collect();
200    }
201
202    if imp.local_name.is_some() {
203        // `const mod = await import('./x')` -> Namespace with local_name
204        return vec![ResolvedImport {
205            info: ImportInfo {
206                source: imp.source.clone(),
207                imported_name: ImportedName::Namespace,
208                local_name: imp.local_name.clone().unwrap_or_default(),
209                is_type_only: false,
210                span: imp.span,
211                source_span: Span::default(),
212            },
213            target,
214        }];
215    }
216
217    // Side-effect only: `await import('./x')` with no assignment
218    vec![ResolvedImport {
219        info: ImportInfo {
220            source: imp.source.clone(),
221            imported_name: ImportedName::SideEffect,
222            local_name: String::new(),
223            is_type_only: false,
224            span: imp.span,
225            source_span: Span::default(),
226        },
227        target,
228    }]
229}
230
231/// Resolve re-export sources (`export { x } from './y'`).
232fn resolve_re_exports(
233    ctx: &ResolveContext,
234    file_path: &Path,
235    re_exports: &[ReExportInfo],
236) -> Vec<ResolvedReExport> {
237    re_exports
238        .iter()
239        .map(|re| ResolvedReExport {
240            info: re.clone(),
241            target: resolve_specifier(ctx, file_path, &re.source),
242        })
243        .collect()
244}
245
246/// Resolve CommonJS `require()` calls.
247/// Destructured requires become Named imports; others become Namespace (conservative).
248fn resolve_require_imports(
249    ctx: &ResolveContext,
250    file_path: &Path,
251    require_calls: &[RequireCallInfo],
252) -> Vec<ResolvedImport> {
253    require_calls
254        .iter()
255        .flat_map(|req| resolve_single_require(ctx, file_path, req))
256        .collect()
257}
258
259/// Convert a single `require()` call into one or more `ResolvedImport` entries.
260fn resolve_single_require(
261    ctx: &ResolveContext,
262    file_path: &Path,
263    req: &RequireCallInfo,
264) -> Vec<ResolvedImport> {
265    let target = resolve_specifier(ctx, file_path, &req.source);
266
267    if req.destructured_names.is_empty() {
268        return vec![ResolvedImport {
269            info: ImportInfo {
270                source: req.source.clone(),
271                imported_name: ImportedName::Namespace,
272                local_name: req.local_name.clone().unwrap_or_default(),
273                is_type_only: false,
274                span: req.span,
275                source_span: Span::default(),
276            },
277            target,
278        }];
279    }
280
281    req.destructured_names
282        .iter()
283        .map(|name| ResolvedImport {
284            info: ImportInfo {
285                source: req.source.clone(),
286                imported_name: ImportedName::Named(name.clone()),
287                local_name: name.clone(),
288                is_type_only: false,
289                span: req.span,
290                source_span: Span::default(),
291            },
292            target: target.clone(),
293        })
294        .collect()
295}
296
297/// Resolve dynamic import patterns via glob matching against discovered files.
298/// Uses pre-computed canonical paths (no syscalls in inner loop).
299fn resolve_dynamic_patterns(
300    from_dir: &Path,
301    patterns: &[DynamicImportPattern],
302    canonical_paths: &[PathBuf],
303    files: &[DiscoveredFile],
304) -> Vec<(DynamicImportPattern, Vec<FileId>)> {
305    patterns
306        .iter()
307        .filter_map(|pattern| {
308            let glob_str = make_glob_from_pattern(pattern);
309            let matcher = globset::Glob::new(&glob_str)
310                .ok()
311                .map(|g| g.compile_matcher())?;
312            let matched: Vec<FileId> = canonical_paths
313                .iter()
314                .enumerate()
315                .filter(|(_idx, canonical)| {
316                    canonical.strip_prefix(from_dir).is_ok_and(|relative| {
317                        let rel_str = format!("./{}", relative.to_string_lossy());
318                        matcher.is_match(&rel_str)
319                    })
320                })
321                .map(|(idx, _)| files[idx].id)
322                .collect();
323            if matched.is_empty() {
324                None
325            } else {
326                Some((pattern.clone(), matched))
327            }
328        })
329        .collect()
330}
331
332/// Post-resolution pass: deterministic specifier upgrade.
333///
334/// With `TsconfigDiscovery::Auto`, the same bare specifier (e.g., `preact/hooks`)
335/// may resolve to `InternalModule` from files under a tsconfig with path aliases
336/// but `NpmPackage` from files without such aliases. The parallel resolution cache
337/// makes the per-file result depend on which thread resolved first (non-deterministic).
338///
339/// Scans all resolved imports/re-exports to find bare specifiers where ANY file resolved
340/// to `InternalModule`. For those specifiers, upgrades all `NpmPackage` results to
341/// `InternalModule`. This is correct because if any tsconfig context maps a specifier to
342/// a project source file, that source file IS the origin of the package.
343///
344/// Note: if two tsconfigs map the same specifier to different `FileId`s, the first one
345/// encountered (by module order = `FileId` order) wins. This is deterministic but may be
346/// imprecise for that edge case — both files get connected regardless.
347fn apply_specifier_upgrades(resolved: &mut [ResolvedModule]) {
348    let mut specifier_upgrades: FxHashMap<String, FileId> = FxHashMap::default();
349    for module in resolved.iter() {
350        for imp in module
351            .resolved_imports
352            .iter()
353            .chain(module.resolved_dynamic_imports.iter())
354        {
355            if is_bare_specifier(&imp.info.source)
356                && let ResolveResult::InternalModule(file_id) = &imp.target
357            {
358                specifier_upgrades
359                    .entry(imp.info.source.clone())
360                    .or_insert(*file_id);
361            }
362        }
363        for re in &module.re_exports {
364            if is_bare_specifier(&re.info.source)
365                && let ResolveResult::InternalModule(file_id) = &re.target
366            {
367                specifier_upgrades
368                    .entry(re.info.source.clone())
369                    .or_insert(*file_id);
370            }
371        }
372    }
373
374    if specifier_upgrades.is_empty() {
375        return;
376    }
377
378    // Apply upgrades: replace NpmPackage with InternalModule for matched specifiers
379    for module in resolved.iter_mut() {
380        for imp in module
381            .resolved_imports
382            .iter_mut()
383            .chain(module.resolved_dynamic_imports.iter_mut())
384        {
385            if matches!(imp.target, ResolveResult::NpmPackage(_))
386                && let Some(&file_id) = specifier_upgrades.get(&imp.info.source)
387            {
388                imp.target = ResolveResult::InternalModule(file_id);
389            }
390        }
391        for re in &mut module.re_exports {
392            if matches!(re.target, ResolveResult::NpmPackage(_))
393                && let Some(&file_id) = specifier_upgrades.get(&re.info.source)
394            {
395                re.target = ResolveResult::InternalModule(file_id);
396            }
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use oxc_span::Span;
405
406    // -----------------------------------------------------------------------
407    // Helpers
408    // -----------------------------------------------------------------------
409
410    fn dummy_span() -> Span {
411        Span::new(0, 0)
412    }
413
414    /// Build a minimal `ResolveContext` backed by a real resolver but with
415    /// empty lookup tables. Every specifier resolves to `NpmPackage` or
416    /// `Unresolvable`, which is fine — the tests focus on how helper functions
417    /// *transform* inputs into `ResolvedImport` / `ResolvedReExport` structs.
418    ///
419    /// Under Miri this is a no-op: `oxc_resolver` uses the `statx` syscall
420    /// (via `rustix`) which Miri does not support.
421    #[cfg(not(miri))]
422    fn with_empty_ctx<F: FnOnce(&ResolveContext)>(f: F) {
423        let resolver = specifier::create_resolver(&[]);
424        let path_to_id = FxHashMap::default();
425        let raw_path_to_id = FxHashMap::default();
426        let workspace_roots = FxHashMap::default();
427        let root = PathBuf::from("/project");
428        let ctx = ResolveContext {
429            resolver: &resolver,
430            path_to_id: &path_to_id,
431            raw_path_to_id: &raw_path_to_id,
432            workspace_roots: &workspace_roots,
433            path_aliases: &[],
434            root: &root,
435        };
436        f(&ctx);
437    }
438
439    #[cfg(miri)]
440    fn with_empty_ctx<F: FnOnce(&ResolveContext)>(_f: F) {
441        // oxc_resolver uses statx syscall unsupported by Miri — skip.
442    }
443
444    fn make_import(source: &str, imported: ImportedName, local: &str) -> ImportInfo {
445        ImportInfo {
446            source: source.to_string(),
447            imported_name: imported,
448            local_name: local.to_string(),
449            is_type_only: false,
450            span: dummy_span(),
451            source_span: Span::default(),
452        }
453    }
454
455    fn make_re_export(source: &str, imported: &str, exported: &str) -> ReExportInfo {
456        ReExportInfo {
457            source: source.to_string(),
458            imported_name: imported.to_string(),
459            exported_name: exported.to_string(),
460            is_type_only: false,
461        }
462    }
463
464    fn make_dynamic(
465        source: &str,
466        destructured: Vec<&str>,
467        local_name: Option<&str>,
468    ) -> DynamicImportInfo {
469        DynamicImportInfo {
470            source: source.to_string(),
471            span: dummy_span(),
472            destructured_names: destructured.into_iter().map(String::from).collect(),
473            local_name: local_name.map(String::from),
474        }
475    }
476
477    fn make_require(
478        source: &str,
479        destructured: Vec<&str>,
480        local_name: Option<&str>,
481    ) -> RequireCallInfo {
482        RequireCallInfo {
483            source: source.to_string(),
484            span: dummy_span(),
485            destructured_names: destructured.into_iter().map(String::from).collect(),
486            local_name: local_name.map(String::from),
487        }
488    }
489
490    /// Build a minimal `ResolvedModule` for `apply_specifier_upgrades` tests.
491    fn make_resolved_module(
492        file_id: u32,
493        imports: Vec<ResolvedImport>,
494        dynamic_imports: Vec<ResolvedImport>,
495        re_exports: Vec<ResolvedReExport>,
496    ) -> ResolvedModule {
497        ResolvedModule {
498            file_id: FileId(file_id),
499            path: PathBuf::from(format!("/project/src/file_{file_id}.ts")),
500            exports: vec![],
501            re_exports,
502            resolved_imports: imports,
503            resolved_dynamic_imports: dynamic_imports,
504            resolved_dynamic_patterns: vec![],
505            member_accesses: vec![],
506            whole_object_uses: vec![],
507            has_cjs_exports: false,
508            unused_import_bindings: vec![],
509        }
510    }
511
512    fn make_resolved_import(source: &str, target: ResolveResult) -> ResolvedImport {
513        ResolvedImport {
514            info: make_import(source, ImportedName::Named("x".into()), "x"),
515            target,
516        }
517    }
518
519    fn make_resolved_re_export(source: &str, target: ResolveResult) -> ResolvedReExport {
520        ResolvedReExport {
521            info: make_re_export(source, "x", "x"),
522            target,
523        }
524    }
525
526    // -----------------------------------------------------------------------
527    // resolve_static_imports
528    // -----------------------------------------------------------------------
529
530    #[test]
531    fn static_imports_named() {
532        with_empty_ctx(|ctx| {
533            let imports = vec![make_import(
534                "react",
535                ImportedName::Named("useState".into()),
536                "useState",
537            )];
538            let file = Path::new("/project/src/app.ts");
539            let result = resolve_static_imports(ctx, file, &imports);
540
541            assert_eq!(result.len(), 1);
542            assert_eq!(result[0].info.source, "react");
543            assert!(matches!(
544                result[0].info.imported_name,
545                ImportedName::Named(ref n) if n == "useState"
546            ));
547        });
548    }
549
550    #[test]
551    fn static_imports_default() {
552        with_empty_ctx(|ctx| {
553            let imports = vec![make_import("react", ImportedName::Default, "React")];
554            let file = Path::new("/project/src/app.ts");
555            let result = resolve_static_imports(ctx, file, &imports);
556
557            assert_eq!(result.len(), 1);
558            assert!(matches!(
559                result[0].info.imported_name,
560                ImportedName::Default
561            ));
562            assert_eq!(result[0].info.local_name, "React");
563        });
564    }
565
566    #[test]
567    fn static_imports_namespace() {
568        with_empty_ctx(|ctx| {
569            let imports = vec![make_import("lodash", ImportedName::Namespace, "_")];
570            let file = Path::new("/project/src/utils.ts");
571            let result = resolve_static_imports(ctx, file, &imports);
572
573            assert_eq!(result.len(), 1);
574            assert!(matches!(
575                result[0].info.imported_name,
576                ImportedName::Namespace
577            ));
578            assert_eq!(result[0].info.local_name, "_");
579        });
580    }
581
582    #[test]
583    fn static_imports_side_effect() {
584        with_empty_ctx(|ctx| {
585            let imports = vec![make_import("./styles.css", ImportedName::SideEffect, "")];
586            let file = Path::new("/project/src/app.ts");
587            let result = resolve_static_imports(ctx, file, &imports);
588
589            assert_eq!(result.len(), 1);
590            assert!(matches!(
591                result[0].info.imported_name,
592                ImportedName::SideEffect
593            ));
594            assert_eq!(result[0].info.local_name, "");
595        });
596    }
597
598    #[test]
599    fn static_imports_empty_list() {
600        with_empty_ctx(|ctx| {
601            let file = Path::new("/project/src/app.ts");
602            let result = resolve_static_imports(ctx, file, &[]);
603            assert!(result.is_empty());
604        });
605    }
606
607    #[test]
608    fn static_imports_multiple() {
609        with_empty_ctx(|ctx| {
610            let imports = vec![
611                make_import("react", ImportedName::Default, "React"),
612                make_import("react", ImportedName::Named("useState".into()), "useState"),
613                make_import("lodash", ImportedName::Namespace, "_"),
614            ];
615            let file = Path::new("/project/src/app.ts");
616            let result = resolve_static_imports(ctx, file, &imports);
617
618            assert_eq!(result.len(), 3);
619            assert_eq!(result[0].info.source, "react");
620            assert_eq!(result[1].info.source, "react");
621            assert_eq!(result[2].info.source, "lodash");
622        });
623    }
624
625    #[test]
626    fn static_imports_preserves_type_only() {
627        with_empty_ctx(|ctx| {
628            let imports = vec![ImportInfo {
629                source: "react".into(),
630                imported_name: ImportedName::Named("FC".into()),
631                local_name: "FC".into(),
632                is_type_only: true,
633                span: dummy_span(),
634                source_span: Span::default(),
635            }];
636            let file = Path::new("/project/src/app.ts");
637            let result = resolve_static_imports(ctx, file, &imports);
638
639            assert_eq!(result.len(), 1);
640            assert!(result[0].info.is_type_only);
641        });
642    }
643
644    // -----------------------------------------------------------------------
645    // resolve_single_dynamic_import
646    // -----------------------------------------------------------------------
647
648    #[test]
649    fn dynamic_import_with_destructured_names() {
650        with_empty_ctx(|ctx| {
651            let imp = make_dynamic("./utils", vec!["foo", "bar"], None);
652            let file = Path::new("/project/src/app.ts");
653            let result = resolve_single_dynamic_import(ctx, file, &imp);
654
655            assert_eq!(result.len(), 2);
656            assert!(matches!(
657                result[0].info.imported_name,
658                ImportedName::Named(ref n) if n == "foo"
659            ));
660            assert_eq!(result[0].info.local_name, "foo");
661            assert!(matches!(
662                result[1].info.imported_name,
663                ImportedName::Named(ref n) if n == "bar"
664            ));
665            assert_eq!(result[1].info.local_name, "bar");
666            // Both should have the same source
667            assert_eq!(result[0].info.source, "./utils");
668            assert_eq!(result[1].info.source, "./utils");
669            // Both should be non-type-only
670            assert!(!result[0].info.is_type_only);
671            assert!(!result[1].info.is_type_only);
672        });
673    }
674
675    #[test]
676    fn dynamic_import_namespace_with_local_name() {
677        with_empty_ctx(|ctx| {
678            let imp = make_dynamic("./utils", vec![], Some("utils"));
679            let file = Path::new("/project/src/app.ts");
680            let result = resolve_single_dynamic_import(ctx, file, &imp);
681
682            assert_eq!(result.len(), 1);
683            assert!(matches!(
684                result[0].info.imported_name,
685                ImportedName::Namespace
686            ));
687            assert_eq!(result[0].info.local_name, "utils");
688        });
689    }
690
691    #[test]
692    fn dynamic_import_side_effect() {
693        with_empty_ctx(|ctx| {
694            let imp = make_dynamic("./polyfill", vec![], None);
695            let file = Path::new("/project/src/app.ts");
696            let result = resolve_single_dynamic_import(ctx, file, &imp);
697
698            assert_eq!(result.len(), 1);
699            assert!(matches!(
700                result[0].info.imported_name,
701                ImportedName::SideEffect
702            ));
703            assert_eq!(result[0].info.local_name, "");
704            assert_eq!(result[0].info.source, "./polyfill");
705        });
706    }
707
708    #[test]
709    fn dynamic_import_destructured_takes_priority_over_local_name() {
710        // When both destructured_names and local_name are set,
711        // destructured_names wins (checked first).
712        with_empty_ctx(|ctx| {
713            let imp = DynamicImportInfo {
714                source: "./mod".into(),
715                span: dummy_span(),
716                destructured_names: vec!["a".into()],
717                local_name: Some("mod".into()),
718            };
719            let file = Path::new("/project/src/app.ts");
720            let result = resolve_single_dynamic_import(ctx, file, &imp);
721
722            assert_eq!(result.len(), 1);
723            assert!(matches!(
724                result[0].info.imported_name,
725                ImportedName::Named(ref n) if n == "a"
726            ));
727        });
728    }
729
730    // -----------------------------------------------------------------------
731    // resolve_dynamic_imports (batch)
732    // -----------------------------------------------------------------------
733
734    #[test]
735    fn dynamic_imports_flattens_multiple() {
736        with_empty_ctx(|ctx| {
737            let imports = vec![
738                make_dynamic("./a", vec!["x", "y"], None),
739                make_dynamic("./b", vec![], Some("b")),
740                make_dynamic("./c", vec![], None),
741            ];
742            let file = Path::new("/project/src/app.ts");
743            let result = resolve_dynamic_imports(ctx, file, &imports);
744
745            // ./a -> 2 Named, ./b -> 1 Namespace, ./c -> 1 SideEffect = 4 total
746            assert_eq!(result.len(), 4);
747        });
748    }
749
750    #[test]
751    fn dynamic_imports_empty_list() {
752        with_empty_ctx(|ctx| {
753            let file = Path::new("/project/src/app.ts");
754            let result = resolve_dynamic_imports(ctx, file, &[]);
755            assert!(result.is_empty());
756        });
757    }
758
759    // -----------------------------------------------------------------------
760    // resolve_re_exports
761    // -----------------------------------------------------------------------
762
763    #[test]
764    fn re_exports_maps_each_entry() {
765        with_empty_ctx(|ctx| {
766            let re_exports = vec![
767                make_re_export("./utils", "helper", "helper"),
768                make_re_export("./types", "*", "*"),
769            ];
770            let file = Path::new("/project/src/index.ts");
771            let result = resolve_re_exports(ctx, file, &re_exports);
772
773            assert_eq!(result.len(), 2);
774            assert_eq!(result[0].info.source, "./utils");
775            assert_eq!(result[0].info.imported_name, "helper");
776            assert_eq!(result[0].info.exported_name, "helper");
777            assert_eq!(result[1].info.source, "./types");
778            assert_eq!(result[1].info.imported_name, "*");
779        });
780    }
781
782    #[test]
783    fn re_exports_empty_list() {
784        with_empty_ctx(|ctx| {
785            let file = Path::new("/project/src/index.ts");
786            let result = resolve_re_exports(ctx, file, &[]);
787            assert!(result.is_empty());
788        });
789    }
790
791    #[test]
792    fn re_exports_preserves_type_only() {
793        with_empty_ctx(|ctx| {
794            let re_exports = vec![ReExportInfo {
795                source: "./types".into(),
796                imported_name: "MyType".into(),
797                exported_name: "MyType".into(),
798                is_type_only: true,
799            }];
800            let file = Path::new("/project/src/index.ts");
801            let result = resolve_re_exports(ctx, file, &re_exports);
802
803            assert_eq!(result.len(), 1);
804            assert!(result[0].info.is_type_only);
805        });
806    }
807
808    // -----------------------------------------------------------------------
809    // resolve_single_require
810    // -----------------------------------------------------------------------
811
812    #[test]
813    fn require_namespace_without_destructuring() {
814        with_empty_ctx(|ctx| {
815            let req = make_require("fs", vec![], Some("fs"));
816            let file = Path::new("/project/src/app.js");
817            let result = resolve_single_require(ctx, file, &req);
818
819            assert_eq!(result.len(), 1);
820            assert!(matches!(
821                result[0].info.imported_name,
822                ImportedName::Namespace
823            ));
824            assert_eq!(result[0].info.local_name, "fs");
825            assert_eq!(result[0].info.source, "fs");
826        });
827    }
828
829    #[test]
830    fn require_namespace_without_local_name() {
831        with_empty_ctx(|ctx| {
832            let req = make_require("./side-effect", vec![], None);
833            let file = Path::new("/project/src/app.js");
834            let result = resolve_single_require(ctx, file, &req);
835
836            assert_eq!(result.len(), 1);
837            assert!(matches!(
838                result[0].info.imported_name,
839                ImportedName::Namespace
840            ));
841            // No local name -> empty string from unwrap_or_default
842            assert_eq!(result[0].info.local_name, "");
843        });
844    }
845
846    #[test]
847    fn require_with_destructured_names() {
848        with_empty_ctx(|ctx| {
849            let req = make_require("path", vec!["join", "resolve"], None);
850            let file = Path::new("/project/src/app.js");
851            let result = resolve_single_require(ctx, file, &req);
852
853            assert_eq!(result.len(), 2);
854            assert!(matches!(
855                result[0].info.imported_name,
856                ImportedName::Named(ref n) if n == "join"
857            ));
858            assert_eq!(result[0].info.local_name, "join");
859            assert!(matches!(
860                result[1].info.imported_name,
861                ImportedName::Named(ref n) if n == "resolve"
862            ));
863            assert_eq!(result[1].info.local_name, "resolve");
864            // Both share the same source
865            assert_eq!(result[0].info.source, "path");
866            assert_eq!(result[1].info.source, "path");
867        });
868    }
869
870    #[test]
871    fn require_destructured_is_not_type_only() {
872        with_empty_ctx(|ctx| {
873            let req = make_require("path", vec!["join"], None);
874            let file = Path::new("/project/src/app.js");
875            let result = resolve_single_require(ctx, file, &req);
876
877            assert_eq!(result.len(), 1);
878            assert!(!result[0].info.is_type_only);
879        });
880    }
881
882    // -----------------------------------------------------------------------
883    // resolve_require_imports (batch)
884    // -----------------------------------------------------------------------
885
886    #[test]
887    fn require_imports_flattens_multiple() {
888        with_empty_ctx(|ctx| {
889            let reqs = vec![
890                make_require("fs", vec![], Some("fs")),
891                make_require("path", vec!["join", "resolve"], None),
892            ];
893            let file = Path::new("/project/src/app.js");
894            let result = resolve_require_imports(ctx, file, &reqs);
895
896            // fs -> 1 Namespace, path -> 2 Named = 3 total
897            assert_eq!(result.len(), 3);
898        });
899    }
900
901    #[test]
902    fn require_imports_empty_list() {
903        with_empty_ctx(|ctx| {
904            let file = Path::new("/project/src/app.js");
905            let result = resolve_require_imports(ctx, file, &[]);
906            assert!(result.is_empty());
907        });
908    }
909
910    // -----------------------------------------------------------------------
911    // apply_specifier_upgrades
912    // -----------------------------------------------------------------------
913
914    #[test]
915    fn specifier_upgrades_npm_to_internal() {
916        // Module 0 resolves `preact/hooks` to InternalModule(FileId(5))
917        // Module 1 resolves `preact/hooks` to NpmPackage("preact")
918        // After upgrade, module 1 should also point to InternalModule(FileId(5))
919        let mut modules = vec![
920            make_resolved_module(
921                0,
922                vec![make_resolved_import(
923                    "preact/hooks",
924                    ResolveResult::InternalModule(FileId(5)),
925                )],
926                vec![],
927                vec![],
928            ),
929            make_resolved_module(
930                1,
931                vec![make_resolved_import(
932                    "preact/hooks",
933                    ResolveResult::NpmPackage("preact".into()),
934                )],
935                vec![],
936                vec![],
937            ),
938        ];
939
940        apply_specifier_upgrades(&mut modules);
941
942        assert!(matches!(
943            modules[1].resolved_imports[0].target,
944            ResolveResult::InternalModule(FileId(5))
945        ));
946    }
947
948    #[test]
949    fn specifier_upgrades_noop_when_no_internal() {
950        // All modules resolve `lodash` to NpmPackage — no upgrade should happen
951        let mut modules = vec![
952            make_resolved_module(
953                0,
954                vec![make_resolved_import(
955                    "lodash",
956                    ResolveResult::NpmPackage("lodash".into()),
957                )],
958                vec![],
959                vec![],
960            ),
961            make_resolved_module(
962                1,
963                vec![make_resolved_import(
964                    "lodash",
965                    ResolveResult::NpmPackage("lodash".into()),
966                )],
967                vec![],
968                vec![],
969            ),
970        ];
971
972        apply_specifier_upgrades(&mut modules);
973
974        assert!(matches!(
975            modules[0].resolved_imports[0].target,
976            ResolveResult::NpmPackage(_)
977        ));
978        assert!(matches!(
979            modules[1].resolved_imports[0].target,
980            ResolveResult::NpmPackage(_)
981        ));
982    }
983
984    #[test]
985    fn specifier_upgrades_empty_modules() {
986        let mut modules: Vec<ResolvedModule> = vec![];
987        apply_specifier_upgrades(&mut modules);
988        assert!(modules.is_empty());
989    }
990
991    #[test]
992    fn specifier_upgrades_skips_relative_specifiers() {
993        // Relative specifiers (./foo) are NOT bare specifiers, so they should
994        // never be candidates for upgrade.
995        let mut modules = vec![
996            make_resolved_module(
997                0,
998                vec![make_resolved_import(
999                    "./utils",
1000                    ResolveResult::InternalModule(FileId(5)),
1001                )],
1002                vec![],
1003                vec![],
1004            ),
1005            make_resolved_module(
1006                1,
1007                vec![make_resolved_import(
1008                    "./utils",
1009                    ResolveResult::NpmPackage("utils".into()),
1010                )],
1011                vec![],
1012                vec![],
1013            ),
1014        ];
1015
1016        apply_specifier_upgrades(&mut modules);
1017
1018        // Module 1 should still be NpmPackage — relative specifier not upgraded
1019        assert!(matches!(
1020            modules[1].resolved_imports[0].target,
1021            ResolveResult::NpmPackage(_)
1022        ));
1023    }
1024
1025    #[test]
1026    fn specifier_upgrades_applies_to_dynamic_imports() {
1027        let mut modules = vec![
1028            make_resolved_module(
1029                0,
1030                vec![],
1031                vec![make_resolved_import(
1032                    "preact/hooks",
1033                    ResolveResult::InternalModule(FileId(5)),
1034                )],
1035                vec![],
1036            ),
1037            make_resolved_module(
1038                1,
1039                vec![],
1040                vec![make_resolved_import(
1041                    "preact/hooks",
1042                    ResolveResult::NpmPackage("preact".into()),
1043                )],
1044                vec![],
1045            ),
1046        ];
1047
1048        apply_specifier_upgrades(&mut modules);
1049
1050        assert!(matches!(
1051            modules[1].resolved_dynamic_imports[0].target,
1052            ResolveResult::InternalModule(FileId(5))
1053        ));
1054    }
1055
1056    #[test]
1057    fn specifier_upgrades_applies_to_re_exports() {
1058        let mut modules = vec![
1059            make_resolved_module(
1060                0,
1061                vec![],
1062                vec![],
1063                vec![make_resolved_re_export(
1064                    "preact/hooks",
1065                    ResolveResult::InternalModule(FileId(5)),
1066                )],
1067            ),
1068            make_resolved_module(
1069                1,
1070                vec![],
1071                vec![],
1072                vec![make_resolved_re_export(
1073                    "preact/hooks",
1074                    ResolveResult::NpmPackage("preact".into()),
1075                )],
1076            ),
1077        ];
1078
1079        apply_specifier_upgrades(&mut modules);
1080
1081        assert!(matches!(
1082            modules[1].re_exports[0].target,
1083            ResolveResult::InternalModule(FileId(5))
1084        ));
1085    }
1086
1087    #[test]
1088    fn specifier_upgrades_does_not_downgrade_internal() {
1089        // If both modules already resolve to InternalModule, nothing changes
1090        let mut modules = vec![
1091            make_resolved_module(
1092                0,
1093                vec![make_resolved_import(
1094                    "preact/hooks",
1095                    ResolveResult::InternalModule(FileId(5)),
1096                )],
1097                vec![],
1098                vec![],
1099            ),
1100            make_resolved_module(
1101                1,
1102                vec![make_resolved_import(
1103                    "preact/hooks",
1104                    ResolveResult::InternalModule(FileId(5)),
1105                )],
1106                vec![],
1107                vec![],
1108            ),
1109        ];
1110
1111        apply_specifier_upgrades(&mut modules);
1112
1113        assert!(matches!(
1114            modules[0].resolved_imports[0].target,
1115            ResolveResult::InternalModule(FileId(5))
1116        ));
1117        assert!(matches!(
1118            modules[1].resolved_imports[0].target,
1119            ResolveResult::InternalModule(FileId(5))
1120        ));
1121    }
1122
1123    #[test]
1124    fn specifier_upgrades_first_internal_wins() {
1125        // Two modules resolve the same bare specifier to different internal files.
1126        // The first one (by module order) wins.
1127        let mut modules = vec![
1128            make_resolved_module(
1129                0,
1130                vec![make_resolved_import(
1131                    "shared-lib",
1132                    ResolveResult::InternalModule(FileId(10)),
1133                )],
1134                vec![],
1135                vec![],
1136            ),
1137            make_resolved_module(
1138                1,
1139                vec![make_resolved_import(
1140                    "shared-lib",
1141                    ResolveResult::InternalModule(FileId(20)),
1142                )],
1143                vec![],
1144                vec![],
1145            ),
1146            make_resolved_module(
1147                2,
1148                vec![make_resolved_import(
1149                    "shared-lib",
1150                    ResolveResult::NpmPackage("shared-lib".into()),
1151                )],
1152                vec![],
1153                vec![],
1154            ),
1155        ];
1156
1157        apply_specifier_upgrades(&mut modules);
1158
1159        // Module 2 should be upgraded to the first FileId encountered (10)
1160        assert!(matches!(
1161            modules[2].resolved_imports[0].target,
1162            ResolveResult::InternalModule(FileId(10))
1163        ));
1164    }
1165
1166    #[test]
1167    fn specifier_upgrades_does_not_touch_unresolvable() {
1168        // Unresolvable should not be upgraded even if a bare specifier
1169        // matches an InternalModule elsewhere.
1170        let mut modules = vec![
1171            make_resolved_module(
1172                0,
1173                vec![make_resolved_import(
1174                    "my-lib",
1175                    ResolveResult::InternalModule(FileId(1)),
1176                )],
1177                vec![],
1178                vec![],
1179            ),
1180            make_resolved_module(
1181                1,
1182                vec![ResolvedImport {
1183                    info: make_import("my-lib", ImportedName::Default, "myLib"),
1184                    target: ResolveResult::Unresolvable("my-lib".into()),
1185                }],
1186                vec![],
1187                vec![],
1188            ),
1189        ];
1190
1191        apply_specifier_upgrades(&mut modules);
1192
1193        // Unresolvable should remain unresolvable
1194        assert!(matches!(
1195            modules[1].resolved_imports[0].target,
1196            ResolveResult::Unresolvable(_)
1197        ));
1198    }
1199
1200    #[test]
1201    fn specifier_upgrades_cross_import_and_re_export() {
1202        // An import in module 0 resolves to InternalModule, a re-export in
1203        // module 1 for the same specifier should also be upgraded.
1204        let mut modules = vec![
1205            make_resolved_module(
1206                0,
1207                vec![make_resolved_import(
1208                    "@myorg/utils",
1209                    ResolveResult::InternalModule(FileId(3)),
1210                )],
1211                vec![],
1212                vec![],
1213            ),
1214            make_resolved_module(
1215                1,
1216                vec![],
1217                vec![],
1218                vec![make_resolved_re_export(
1219                    "@myorg/utils",
1220                    ResolveResult::NpmPackage("@myorg/utils".into()),
1221                )],
1222            ),
1223        ];
1224
1225        apply_specifier_upgrades(&mut modules);
1226
1227        assert!(matches!(
1228            modules[1].re_exports[0].target,
1229            ResolveResult::InternalModule(FileId(3))
1230        ));
1231    }
1232
1233    // -----------------------------------------------------------------------
1234    // resolve_dynamic_patterns
1235    // -----------------------------------------------------------------------
1236
1237    #[test]
1238    fn dynamic_patterns_matches_files_in_dir() {
1239        let from_dir = Path::new("/project/src");
1240        let patterns = vec![DynamicImportPattern {
1241            prefix: "./locales/".into(),
1242            suffix: Some(".json".into()),
1243            span: dummy_span(),
1244        }];
1245        let canonical_paths = vec![
1246            PathBuf::from("/project/src/locales/en.json"),
1247            PathBuf::from("/project/src/locales/fr.json"),
1248            PathBuf::from("/project/src/utils.ts"),
1249        ];
1250        let files = vec![
1251            DiscoveredFile {
1252                id: FileId(0),
1253                path: PathBuf::from("/project/src/locales/en.json"),
1254                size_bytes: 100,
1255            },
1256            DiscoveredFile {
1257                id: FileId(1),
1258                path: PathBuf::from("/project/src/locales/fr.json"),
1259                size_bytes: 100,
1260            },
1261            DiscoveredFile {
1262                id: FileId(2),
1263                path: PathBuf::from("/project/src/utils.ts"),
1264                size_bytes: 100,
1265            },
1266        ];
1267
1268        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1269
1270        assert_eq!(result.len(), 1);
1271        assert_eq!(result[0].1.len(), 2);
1272        assert!(result[0].1.contains(&FileId(0)));
1273        assert!(result[0].1.contains(&FileId(1)));
1274    }
1275
1276    #[test]
1277    fn dynamic_patterns_no_matches_returns_empty() {
1278        let from_dir = Path::new("/project/src");
1279        let patterns = vec![DynamicImportPattern {
1280            prefix: "./locales/".into(),
1281            suffix: Some(".json".into()),
1282            span: dummy_span(),
1283        }];
1284        let canonical_paths = vec![PathBuf::from("/project/src/utils.ts")];
1285        let files = vec![DiscoveredFile {
1286            id: FileId(0),
1287            path: PathBuf::from("/project/src/utils.ts"),
1288            size_bytes: 100,
1289        }];
1290
1291        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1292
1293        assert!(result.is_empty());
1294    }
1295
1296    #[test]
1297    fn dynamic_patterns_empty_patterns_list() {
1298        let from_dir = Path::new("/project/src");
1299        let canonical_paths = vec![PathBuf::from("/project/src/utils.ts")];
1300        let files = vec![DiscoveredFile {
1301            id: FileId(0),
1302            path: PathBuf::from("/project/src/utils.ts"),
1303            size_bytes: 100,
1304        }];
1305
1306        let result = resolve_dynamic_patterns(from_dir, &[], &canonical_paths, &files);
1307        assert!(result.is_empty());
1308    }
1309
1310    #[test]
1311    fn dynamic_patterns_glob_prefix_passthrough() {
1312        let from_dir = Path::new("/project/src");
1313        let patterns = vec![DynamicImportPattern {
1314            prefix: "./**/*.ts".into(),
1315            suffix: None,
1316            span: dummy_span(),
1317        }];
1318        let canonical_paths = vec![
1319            PathBuf::from("/project/src/utils.ts"),
1320            PathBuf::from("/project/src/deep/nested.ts"),
1321        ];
1322        let files = vec![
1323            DiscoveredFile {
1324                id: FileId(0),
1325                path: PathBuf::from("/project/src/utils.ts"),
1326                size_bytes: 100,
1327            },
1328            DiscoveredFile {
1329                id: FileId(1),
1330                path: PathBuf::from("/project/src/deep/nested.ts"),
1331                size_bytes: 100,
1332            },
1333        ];
1334
1335        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1336
1337        assert_eq!(result.len(), 1);
1338        assert_eq!(result[0].1.len(), 2);
1339    }
1340
1341    // -----------------------------------------------------------------------
1342    // Unresolvable specifier handling
1343    // -----------------------------------------------------------------------
1344
1345    #[test]
1346    fn static_import_unresolvable_relative_path() {
1347        with_empty_ctx(|ctx| {
1348            let imports = vec![make_import(
1349                "./nonexistent",
1350                ImportedName::Default,
1351                "missing",
1352            )];
1353            let file = Path::new("/project/src/app.ts");
1354            let result = resolve_static_imports(ctx, file, &imports);
1355
1356            assert_eq!(result.len(), 1);
1357            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1358        });
1359    }
1360
1361    #[test]
1362    fn static_import_bare_specifier_becomes_npm_package() {
1363        with_empty_ctx(|ctx| {
1364            let imports = vec![make_import("react", ImportedName::Default, "React")];
1365            let file = Path::new("/project/src/app.ts");
1366            let result = resolve_static_imports(ctx, file, &imports);
1367
1368            assert_eq!(result.len(), 1);
1369            assert!(matches!(
1370                result[0].target,
1371                ResolveResult::NpmPackage(ref pkg) if pkg == "react"
1372            ));
1373        });
1374    }
1375
1376    #[test]
1377    fn require_bare_specifier_becomes_npm_package() {
1378        with_empty_ctx(|ctx| {
1379            let req = make_require("express", vec![], Some("express"));
1380            let file = Path::new("/project/src/app.js");
1381            let result = resolve_single_require(ctx, file, &req);
1382
1383            assert_eq!(result.len(), 1);
1384            assert!(matches!(
1385                result[0].target,
1386                ResolveResult::NpmPackage(ref pkg) if pkg == "express"
1387            ));
1388        });
1389    }
1390
1391    #[test]
1392    fn dynamic_import_unresolvable() {
1393        with_empty_ctx(|ctx| {
1394            let imp = make_dynamic("./missing-module", vec![], None);
1395            let file = Path::new("/project/src/app.ts");
1396            let result = resolve_single_dynamic_import(ctx, file, &imp);
1397
1398            assert_eq!(result.len(), 1);
1399            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1400        });
1401    }
1402
1403    #[test]
1404    fn re_export_unresolvable() {
1405        with_empty_ctx(|ctx| {
1406            let re_exports = vec![make_re_export("./missing", "foo", "foo")];
1407            let file = Path::new("/project/src/index.ts");
1408            let result = resolve_re_exports(ctx, file, &re_exports);
1409
1410            assert_eq!(result.len(), 1);
1411            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1412        });
1413    }
1414}