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.clone(),
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 super::*;
448    use oxc_span::Span;
449
450    // -----------------------------------------------------------------------
451    // Helpers
452    // -----------------------------------------------------------------------
453
454    fn dummy_span() -> Span {
455        Span::new(0, 0)
456    }
457
458    /// Build a minimal `ResolveContext` backed by a real resolver but with
459    /// empty lookup tables. Every specifier resolves to `NpmPackage` or
460    /// `Unresolvable`, which is fine — the tests focus on how helper functions
461    /// *transform* inputs into `ResolvedImport` / `ResolvedReExport` structs.
462    ///
463    /// Under Miri this is a no-op: `oxc_resolver` uses the `statx` syscall
464    /// (via `rustix`) which Miri does not support.
465    #[cfg(not(miri))]
466    fn with_empty_ctx<F: FnOnce(&ResolveContext)>(f: F) {
467        let resolver = specifier::create_resolver(&[]);
468        let path_to_id = FxHashMap::default();
469        let raw_path_to_id = FxHashMap::default();
470        let workspace_roots = FxHashMap::default();
471        let root = PathBuf::from("/project");
472        let ctx = ResolveContext {
473            resolver: &resolver,
474            path_to_id: &path_to_id,
475            raw_path_to_id: &raw_path_to_id,
476            workspace_roots: &workspace_roots,
477            path_aliases: &[],
478            root: &root,
479            canonical_fallback: None,
480        };
481        f(&ctx);
482    }
483
484    #[cfg(miri)]
485    fn with_empty_ctx<F: FnOnce(&ResolveContext)>(_f: F) {
486        // oxc_resolver uses statx syscall unsupported by Miri — skip.
487    }
488
489    fn make_import(source: &str, imported: ImportedName, local: &str) -> ImportInfo {
490        ImportInfo {
491            source: source.to_string(),
492            imported_name: imported,
493            local_name: local.to_string(),
494            is_type_only: false,
495            span: dummy_span(),
496            source_span: Span::default(),
497        }
498    }
499
500    fn make_re_export(source: &str, imported: &str, exported: &str) -> ReExportInfo {
501        ReExportInfo {
502            source: source.to_string(),
503            imported_name: imported.to_string(),
504            exported_name: exported.to_string(),
505            is_type_only: false,
506        }
507    }
508
509    fn make_dynamic(
510        source: &str,
511        destructured: Vec<&str>,
512        local_name: Option<&str>,
513    ) -> DynamicImportInfo {
514        DynamicImportInfo {
515            source: source.to_string(),
516            span: dummy_span(),
517            destructured_names: destructured.into_iter().map(String::from).collect(),
518            local_name: local_name.map(String::from),
519        }
520    }
521
522    fn make_require(
523        source: &str,
524        destructured: Vec<&str>,
525        local_name: Option<&str>,
526    ) -> RequireCallInfo {
527        RequireCallInfo {
528            source: source.to_string(),
529            span: dummy_span(),
530            destructured_names: destructured.into_iter().map(String::from).collect(),
531            local_name: local_name.map(String::from),
532        }
533    }
534
535    /// Build a minimal `ResolvedModule` for `apply_specifier_upgrades` tests.
536    fn make_resolved_module(
537        file_id: u32,
538        imports: Vec<ResolvedImport>,
539        dynamic_imports: Vec<ResolvedImport>,
540        re_exports: Vec<ResolvedReExport>,
541    ) -> ResolvedModule {
542        ResolvedModule {
543            file_id: FileId(file_id),
544            path: PathBuf::from(format!("/project/src/file_{file_id}.ts")),
545            exports: vec![],
546            re_exports,
547            resolved_imports: imports,
548            resolved_dynamic_imports: dynamic_imports,
549            resolved_dynamic_patterns: vec![],
550            member_accesses: vec![],
551            whole_object_uses: vec![],
552            has_cjs_exports: false,
553            unused_import_bindings: vec![],
554        }
555    }
556
557    fn make_resolved_import(source: &str, target: ResolveResult) -> ResolvedImport {
558        ResolvedImport {
559            info: make_import(source, ImportedName::Named("x".into()), "x"),
560            target,
561        }
562    }
563
564    fn make_resolved_re_export(source: &str, target: ResolveResult) -> ResolvedReExport {
565        ResolvedReExport {
566            info: make_re_export(source, "x", "x"),
567            target,
568        }
569    }
570
571    // -----------------------------------------------------------------------
572    // resolve_static_imports
573    // -----------------------------------------------------------------------
574
575    #[test]
576    fn static_imports_named() {
577        with_empty_ctx(|ctx| {
578            let imports = vec![make_import(
579                "react",
580                ImportedName::Named("useState".into()),
581                "useState",
582            )];
583            let file = Path::new("/project/src/app.ts");
584            let result = resolve_static_imports(ctx, file, &imports);
585
586            assert_eq!(result.len(), 1);
587            assert_eq!(result[0].info.source, "react");
588            assert!(matches!(
589                result[0].info.imported_name,
590                ImportedName::Named(ref n) if n == "useState"
591            ));
592        });
593    }
594
595    #[test]
596    fn static_imports_default() {
597        with_empty_ctx(|ctx| {
598            let imports = vec![make_import("react", ImportedName::Default, "React")];
599            let file = Path::new("/project/src/app.ts");
600            let result = resolve_static_imports(ctx, file, &imports);
601
602            assert_eq!(result.len(), 1);
603            assert!(matches!(
604                result[0].info.imported_name,
605                ImportedName::Default
606            ));
607            assert_eq!(result[0].info.local_name, "React");
608        });
609    }
610
611    #[test]
612    fn static_imports_namespace() {
613        with_empty_ctx(|ctx| {
614            let imports = vec![make_import("lodash", ImportedName::Namespace, "_")];
615            let file = Path::new("/project/src/utils.ts");
616            let result = resolve_static_imports(ctx, file, &imports);
617
618            assert_eq!(result.len(), 1);
619            assert!(matches!(
620                result[0].info.imported_name,
621                ImportedName::Namespace
622            ));
623            assert_eq!(result[0].info.local_name, "_");
624        });
625    }
626
627    #[test]
628    fn static_imports_side_effect() {
629        with_empty_ctx(|ctx| {
630            let imports = vec![make_import("./styles.css", ImportedName::SideEffect, "")];
631            let file = Path::new("/project/src/app.ts");
632            let result = resolve_static_imports(ctx, file, &imports);
633
634            assert_eq!(result.len(), 1);
635            assert!(matches!(
636                result[0].info.imported_name,
637                ImportedName::SideEffect
638            ));
639            assert_eq!(result[0].info.local_name, "");
640        });
641    }
642
643    #[test]
644    fn static_imports_empty_list() {
645        with_empty_ctx(|ctx| {
646            let file = Path::new("/project/src/app.ts");
647            let result = resolve_static_imports(ctx, file, &[]);
648            assert!(result.is_empty());
649        });
650    }
651
652    #[test]
653    fn static_imports_multiple() {
654        with_empty_ctx(|ctx| {
655            let imports = vec![
656                make_import("react", ImportedName::Default, "React"),
657                make_import("react", ImportedName::Named("useState".into()), "useState"),
658                make_import("lodash", ImportedName::Namespace, "_"),
659            ];
660            let file = Path::new("/project/src/app.ts");
661            let result = resolve_static_imports(ctx, file, &imports);
662
663            assert_eq!(result.len(), 3);
664            assert_eq!(result[0].info.source, "react");
665            assert_eq!(result[1].info.source, "react");
666            assert_eq!(result[2].info.source, "lodash");
667        });
668    }
669
670    #[test]
671    fn static_imports_preserves_type_only() {
672        with_empty_ctx(|ctx| {
673            let imports = vec![ImportInfo {
674                source: "react".into(),
675                imported_name: ImportedName::Named("FC".into()),
676                local_name: "FC".into(),
677                is_type_only: true,
678                span: dummy_span(),
679                source_span: Span::default(),
680            }];
681            let file = Path::new("/project/src/app.ts");
682            let result = resolve_static_imports(ctx, file, &imports);
683
684            assert_eq!(result.len(), 1);
685            assert!(result[0].info.is_type_only);
686        });
687    }
688
689    // -----------------------------------------------------------------------
690    // resolve_single_dynamic_import
691    // -----------------------------------------------------------------------
692
693    #[test]
694    fn dynamic_import_with_destructured_names() {
695        with_empty_ctx(|ctx| {
696            let imp = make_dynamic("./utils", vec!["foo", "bar"], None);
697            let file = Path::new("/project/src/app.ts");
698            let result = resolve_single_dynamic_import(ctx, file, &imp);
699
700            assert_eq!(result.len(), 2);
701            assert!(matches!(
702                result[0].info.imported_name,
703                ImportedName::Named(ref n) if n == "foo"
704            ));
705            assert_eq!(result[0].info.local_name, "foo");
706            assert!(matches!(
707                result[1].info.imported_name,
708                ImportedName::Named(ref n) if n == "bar"
709            ));
710            assert_eq!(result[1].info.local_name, "bar");
711            // Both should have the same source
712            assert_eq!(result[0].info.source, "./utils");
713            assert_eq!(result[1].info.source, "./utils");
714            // Both should be non-type-only
715            assert!(!result[0].info.is_type_only);
716            assert!(!result[1].info.is_type_only);
717        });
718    }
719
720    #[test]
721    fn dynamic_import_namespace_with_local_name() {
722        with_empty_ctx(|ctx| {
723            let imp = make_dynamic("./utils", vec![], Some("utils"));
724            let file = Path::new("/project/src/app.ts");
725            let result = resolve_single_dynamic_import(ctx, file, &imp);
726
727            assert_eq!(result.len(), 1);
728            assert!(matches!(
729                result[0].info.imported_name,
730                ImportedName::Namespace
731            ));
732            assert_eq!(result[0].info.local_name, "utils");
733        });
734    }
735
736    #[test]
737    fn dynamic_import_side_effect() {
738        with_empty_ctx(|ctx| {
739            let imp = make_dynamic("./polyfill", vec![], None);
740            let file = Path::new("/project/src/app.ts");
741            let result = resolve_single_dynamic_import(ctx, file, &imp);
742
743            assert_eq!(result.len(), 1);
744            assert!(matches!(
745                result[0].info.imported_name,
746                ImportedName::SideEffect
747            ));
748            assert_eq!(result[0].info.local_name, "");
749            assert_eq!(result[0].info.source, "./polyfill");
750        });
751    }
752
753    #[test]
754    fn dynamic_import_destructured_takes_priority_over_local_name() {
755        // When both destructured_names and local_name are set,
756        // destructured_names wins (checked first).
757        with_empty_ctx(|ctx| {
758            let imp = DynamicImportInfo {
759                source: "./mod".into(),
760                span: dummy_span(),
761                destructured_names: vec!["a".into()],
762                local_name: Some("mod".into()),
763            };
764            let file = Path::new("/project/src/app.ts");
765            let result = resolve_single_dynamic_import(ctx, file, &imp);
766
767            assert_eq!(result.len(), 1);
768            assert!(matches!(
769                result[0].info.imported_name,
770                ImportedName::Named(ref n) if n == "a"
771            ));
772        });
773    }
774
775    // -----------------------------------------------------------------------
776    // resolve_dynamic_imports (batch)
777    // -----------------------------------------------------------------------
778
779    #[test]
780    fn dynamic_imports_flattens_multiple() {
781        with_empty_ctx(|ctx| {
782            let imports = vec![
783                make_dynamic("./a", vec!["x", "y"], None),
784                make_dynamic("./b", vec![], Some("b")),
785                make_dynamic("./c", vec![], None),
786            ];
787            let file = Path::new("/project/src/app.ts");
788            let result = resolve_dynamic_imports(ctx, file, &imports);
789
790            // ./a -> 2 Named, ./b -> 1 Namespace, ./c -> 1 SideEffect = 4 total
791            assert_eq!(result.len(), 4);
792        });
793    }
794
795    #[test]
796    fn dynamic_imports_empty_list() {
797        with_empty_ctx(|ctx| {
798            let file = Path::new("/project/src/app.ts");
799            let result = resolve_dynamic_imports(ctx, file, &[]);
800            assert!(result.is_empty());
801        });
802    }
803
804    // -----------------------------------------------------------------------
805    // resolve_re_exports
806    // -----------------------------------------------------------------------
807
808    #[test]
809    fn re_exports_maps_each_entry() {
810        with_empty_ctx(|ctx| {
811            let re_exports = vec![
812                make_re_export("./utils", "helper", "helper"),
813                make_re_export("./types", "*", "*"),
814            ];
815            let file = Path::new("/project/src/index.ts");
816            let result = resolve_re_exports(ctx, file, &re_exports);
817
818            assert_eq!(result.len(), 2);
819            assert_eq!(result[0].info.source, "./utils");
820            assert_eq!(result[0].info.imported_name, "helper");
821            assert_eq!(result[0].info.exported_name, "helper");
822            assert_eq!(result[1].info.source, "./types");
823            assert_eq!(result[1].info.imported_name, "*");
824        });
825    }
826
827    #[test]
828    fn re_exports_empty_list() {
829        with_empty_ctx(|ctx| {
830            let file = Path::new("/project/src/index.ts");
831            let result = resolve_re_exports(ctx, file, &[]);
832            assert!(result.is_empty());
833        });
834    }
835
836    #[test]
837    fn re_exports_preserves_type_only() {
838        with_empty_ctx(|ctx| {
839            let re_exports = vec![ReExportInfo {
840                source: "./types".into(),
841                imported_name: "MyType".into(),
842                exported_name: "MyType".into(),
843                is_type_only: true,
844            }];
845            let file = Path::new("/project/src/index.ts");
846            let result = resolve_re_exports(ctx, file, &re_exports);
847
848            assert_eq!(result.len(), 1);
849            assert!(result[0].info.is_type_only);
850        });
851    }
852
853    // -----------------------------------------------------------------------
854    // resolve_single_require
855    // -----------------------------------------------------------------------
856
857    #[test]
858    fn require_namespace_without_destructuring() {
859        with_empty_ctx(|ctx| {
860            let req = make_require("fs", vec![], Some("fs"));
861            let file = Path::new("/project/src/app.js");
862            let result = resolve_single_require(ctx, file, &req);
863
864            assert_eq!(result.len(), 1);
865            assert!(matches!(
866                result[0].info.imported_name,
867                ImportedName::Namespace
868            ));
869            assert_eq!(result[0].info.local_name, "fs");
870            assert_eq!(result[0].info.source, "fs");
871        });
872    }
873
874    #[test]
875    fn require_namespace_without_local_name() {
876        with_empty_ctx(|ctx| {
877            let req = make_require("./side-effect", vec![], None);
878            let file = Path::new("/project/src/app.js");
879            let result = resolve_single_require(ctx, file, &req);
880
881            assert_eq!(result.len(), 1);
882            assert!(matches!(
883                result[0].info.imported_name,
884                ImportedName::Namespace
885            ));
886            // No local name -> empty string from unwrap_or_default
887            assert_eq!(result[0].info.local_name, "");
888        });
889    }
890
891    #[test]
892    fn require_with_destructured_names() {
893        with_empty_ctx(|ctx| {
894            let req = make_require("path", vec!["join", "resolve"], None);
895            let file = Path::new("/project/src/app.js");
896            let result = resolve_single_require(ctx, file, &req);
897
898            assert_eq!(result.len(), 2);
899            assert!(matches!(
900                result[0].info.imported_name,
901                ImportedName::Named(ref n) if n == "join"
902            ));
903            assert_eq!(result[0].info.local_name, "join");
904            assert!(matches!(
905                result[1].info.imported_name,
906                ImportedName::Named(ref n) if n == "resolve"
907            ));
908            assert_eq!(result[1].info.local_name, "resolve");
909            // Both share the same source
910            assert_eq!(result[0].info.source, "path");
911            assert_eq!(result[1].info.source, "path");
912        });
913    }
914
915    #[test]
916    fn require_destructured_is_not_type_only() {
917        with_empty_ctx(|ctx| {
918            let req = make_require("path", vec!["join"], None);
919            let file = Path::new("/project/src/app.js");
920            let result = resolve_single_require(ctx, file, &req);
921
922            assert_eq!(result.len(), 1);
923            assert!(!result[0].info.is_type_only);
924        });
925    }
926
927    // -----------------------------------------------------------------------
928    // resolve_require_imports (batch)
929    // -----------------------------------------------------------------------
930
931    #[test]
932    fn require_imports_flattens_multiple() {
933        with_empty_ctx(|ctx| {
934            let reqs = vec![
935                make_require("fs", vec![], Some("fs")),
936                make_require("path", vec!["join", "resolve"], None),
937            ];
938            let file = Path::new("/project/src/app.js");
939            let result = resolve_require_imports(ctx, file, &reqs);
940
941            // fs -> 1 Namespace, path -> 2 Named = 3 total
942            assert_eq!(result.len(), 3);
943        });
944    }
945
946    #[test]
947    fn require_imports_empty_list() {
948        with_empty_ctx(|ctx| {
949            let file = Path::new("/project/src/app.js");
950            let result = resolve_require_imports(ctx, file, &[]);
951            assert!(result.is_empty());
952        });
953    }
954
955    // -----------------------------------------------------------------------
956    // apply_specifier_upgrades
957    // -----------------------------------------------------------------------
958
959    #[test]
960    fn specifier_upgrades_npm_to_internal() {
961        // Module 0 resolves `preact/hooks` to InternalModule(FileId(5))
962        // Module 1 resolves `preact/hooks` to NpmPackage("preact")
963        // After upgrade, module 1 should also point to InternalModule(FileId(5))
964        let mut modules = vec![
965            make_resolved_module(
966                0,
967                vec![make_resolved_import(
968                    "preact/hooks",
969                    ResolveResult::InternalModule(FileId(5)),
970                )],
971                vec![],
972                vec![],
973            ),
974            make_resolved_module(
975                1,
976                vec![make_resolved_import(
977                    "preact/hooks",
978                    ResolveResult::NpmPackage("preact".into()),
979                )],
980                vec![],
981                vec![],
982            ),
983        ];
984
985        apply_specifier_upgrades(&mut modules);
986
987        assert!(matches!(
988            modules[1].resolved_imports[0].target,
989            ResolveResult::InternalModule(FileId(5))
990        ));
991    }
992
993    #[test]
994    fn specifier_upgrades_noop_when_no_internal() {
995        // All modules resolve `lodash` to NpmPackage — no upgrade should happen
996        let mut modules = vec![
997            make_resolved_module(
998                0,
999                vec![make_resolved_import(
1000                    "lodash",
1001                    ResolveResult::NpmPackage("lodash".into()),
1002                )],
1003                vec![],
1004                vec![],
1005            ),
1006            make_resolved_module(
1007                1,
1008                vec![make_resolved_import(
1009                    "lodash",
1010                    ResolveResult::NpmPackage("lodash".into()),
1011                )],
1012                vec![],
1013                vec![],
1014            ),
1015        ];
1016
1017        apply_specifier_upgrades(&mut modules);
1018
1019        assert!(matches!(
1020            modules[0].resolved_imports[0].target,
1021            ResolveResult::NpmPackage(_)
1022        ));
1023        assert!(matches!(
1024            modules[1].resolved_imports[0].target,
1025            ResolveResult::NpmPackage(_)
1026        ));
1027    }
1028
1029    #[test]
1030    fn specifier_upgrades_empty_modules() {
1031        let mut modules: Vec<ResolvedModule> = vec![];
1032        apply_specifier_upgrades(&mut modules);
1033        assert!(modules.is_empty());
1034    }
1035
1036    #[test]
1037    fn specifier_upgrades_skips_relative_specifiers() {
1038        // Relative specifiers (./foo) are NOT bare specifiers, so they should
1039        // never be candidates for upgrade.
1040        let mut modules = vec![
1041            make_resolved_module(
1042                0,
1043                vec![make_resolved_import(
1044                    "./utils",
1045                    ResolveResult::InternalModule(FileId(5)),
1046                )],
1047                vec![],
1048                vec![],
1049            ),
1050            make_resolved_module(
1051                1,
1052                vec![make_resolved_import(
1053                    "./utils",
1054                    ResolveResult::NpmPackage("utils".into()),
1055                )],
1056                vec![],
1057                vec![],
1058            ),
1059        ];
1060
1061        apply_specifier_upgrades(&mut modules);
1062
1063        // Module 1 should still be NpmPackage — relative specifier not upgraded
1064        assert!(matches!(
1065            modules[1].resolved_imports[0].target,
1066            ResolveResult::NpmPackage(_)
1067        ));
1068    }
1069
1070    #[test]
1071    fn specifier_upgrades_applies_to_dynamic_imports() {
1072        let mut modules = vec![
1073            make_resolved_module(
1074                0,
1075                vec![],
1076                vec![make_resolved_import(
1077                    "preact/hooks",
1078                    ResolveResult::InternalModule(FileId(5)),
1079                )],
1080                vec![],
1081            ),
1082            make_resolved_module(
1083                1,
1084                vec![],
1085                vec![make_resolved_import(
1086                    "preact/hooks",
1087                    ResolveResult::NpmPackage("preact".into()),
1088                )],
1089                vec![],
1090            ),
1091        ];
1092
1093        apply_specifier_upgrades(&mut modules);
1094
1095        assert!(matches!(
1096            modules[1].resolved_dynamic_imports[0].target,
1097            ResolveResult::InternalModule(FileId(5))
1098        ));
1099    }
1100
1101    #[test]
1102    fn specifier_upgrades_applies_to_re_exports() {
1103        let mut modules = vec![
1104            make_resolved_module(
1105                0,
1106                vec![],
1107                vec![],
1108                vec![make_resolved_re_export(
1109                    "preact/hooks",
1110                    ResolveResult::InternalModule(FileId(5)),
1111                )],
1112            ),
1113            make_resolved_module(
1114                1,
1115                vec![],
1116                vec![],
1117                vec![make_resolved_re_export(
1118                    "preact/hooks",
1119                    ResolveResult::NpmPackage("preact".into()),
1120                )],
1121            ),
1122        ];
1123
1124        apply_specifier_upgrades(&mut modules);
1125
1126        assert!(matches!(
1127            modules[1].re_exports[0].target,
1128            ResolveResult::InternalModule(FileId(5))
1129        ));
1130    }
1131
1132    #[test]
1133    fn specifier_upgrades_does_not_downgrade_internal() {
1134        // If both modules already resolve to InternalModule, nothing changes
1135        let mut modules = vec![
1136            make_resolved_module(
1137                0,
1138                vec![make_resolved_import(
1139                    "preact/hooks",
1140                    ResolveResult::InternalModule(FileId(5)),
1141                )],
1142                vec![],
1143                vec![],
1144            ),
1145            make_resolved_module(
1146                1,
1147                vec![make_resolved_import(
1148                    "preact/hooks",
1149                    ResolveResult::InternalModule(FileId(5)),
1150                )],
1151                vec![],
1152                vec![],
1153            ),
1154        ];
1155
1156        apply_specifier_upgrades(&mut modules);
1157
1158        assert!(matches!(
1159            modules[0].resolved_imports[0].target,
1160            ResolveResult::InternalModule(FileId(5))
1161        ));
1162        assert!(matches!(
1163            modules[1].resolved_imports[0].target,
1164            ResolveResult::InternalModule(FileId(5))
1165        ));
1166    }
1167
1168    #[test]
1169    fn specifier_upgrades_first_internal_wins() {
1170        // Two modules resolve the same bare specifier to different internal files.
1171        // The first one (by module order) wins.
1172        let mut modules = vec![
1173            make_resolved_module(
1174                0,
1175                vec![make_resolved_import(
1176                    "shared-lib",
1177                    ResolveResult::InternalModule(FileId(10)),
1178                )],
1179                vec![],
1180                vec![],
1181            ),
1182            make_resolved_module(
1183                1,
1184                vec![make_resolved_import(
1185                    "shared-lib",
1186                    ResolveResult::InternalModule(FileId(20)),
1187                )],
1188                vec![],
1189                vec![],
1190            ),
1191            make_resolved_module(
1192                2,
1193                vec![make_resolved_import(
1194                    "shared-lib",
1195                    ResolveResult::NpmPackage("shared-lib".into()),
1196                )],
1197                vec![],
1198                vec![],
1199            ),
1200        ];
1201
1202        apply_specifier_upgrades(&mut modules);
1203
1204        // Module 2 should be upgraded to the first FileId encountered (10)
1205        assert!(matches!(
1206            modules[2].resolved_imports[0].target,
1207            ResolveResult::InternalModule(FileId(10))
1208        ));
1209    }
1210
1211    #[test]
1212    fn specifier_upgrades_does_not_touch_unresolvable() {
1213        // Unresolvable should not be upgraded even if a bare specifier
1214        // matches an InternalModule elsewhere.
1215        let mut modules = vec![
1216            make_resolved_module(
1217                0,
1218                vec![make_resolved_import(
1219                    "my-lib",
1220                    ResolveResult::InternalModule(FileId(1)),
1221                )],
1222                vec![],
1223                vec![],
1224            ),
1225            make_resolved_module(
1226                1,
1227                vec![ResolvedImport {
1228                    info: make_import("my-lib", ImportedName::Default, "myLib"),
1229                    target: ResolveResult::Unresolvable("my-lib".into()),
1230                }],
1231                vec![],
1232                vec![],
1233            ),
1234        ];
1235
1236        apply_specifier_upgrades(&mut modules);
1237
1238        // Unresolvable should remain unresolvable
1239        assert!(matches!(
1240            modules[1].resolved_imports[0].target,
1241            ResolveResult::Unresolvable(_)
1242        ));
1243    }
1244
1245    #[test]
1246    fn specifier_upgrades_cross_import_and_re_export() {
1247        // An import in module 0 resolves to InternalModule, a re-export in
1248        // module 1 for the same specifier should also be upgraded.
1249        let mut modules = vec![
1250            make_resolved_module(
1251                0,
1252                vec![make_resolved_import(
1253                    "@myorg/utils",
1254                    ResolveResult::InternalModule(FileId(3)),
1255                )],
1256                vec![],
1257                vec![],
1258            ),
1259            make_resolved_module(
1260                1,
1261                vec![],
1262                vec![],
1263                vec![make_resolved_re_export(
1264                    "@myorg/utils",
1265                    ResolveResult::NpmPackage("@myorg/utils".into()),
1266                )],
1267            ),
1268        ];
1269
1270        apply_specifier_upgrades(&mut modules);
1271
1272        assert!(matches!(
1273            modules[1].re_exports[0].target,
1274            ResolveResult::InternalModule(FileId(3))
1275        ));
1276    }
1277
1278    // -----------------------------------------------------------------------
1279    // resolve_dynamic_patterns
1280    // -----------------------------------------------------------------------
1281
1282    #[test]
1283    fn dynamic_patterns_matches_files_in_dir() {
1284        let from_dir = Path::new("/project/src");
1285        let patterns = vec![DynamicImportPattern {
1286            prefix: "./locales/".into(),
1287            suffix: Some(".json".into()),
1288            span: dummy_span(),
1289        }];
1290        let canonical_paths = vec![
1291            PathBuf::from("/project/src/locales/en.json"),
1292            PathBuf::from("/project/src/locales/fr.json"),
1293            PathBuf::from("/project/src/utils.ts"),
1294        ];
1295        let files = vec![
1296            DiscoveredFile {
1297                id: FileId(0),
1298                path: PathBuf::from("/project/src/locales/en.json"),
1299                size_bytes: 100,
1300            },
1301            DiscoveredFile {
1302                id: FileId(1),
1303                path: PathBuf::from("/project/src/locales/fr.json"),
1304                size_bytes: 100,
1305            },
1306            DiscoveredFile {
1307                id: FileId(2),
1308                path: PathBuf::from("/project/src/utils.ts"),
1309                size_bytes: 100,
1310            },
1311        ];
1312
1313        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1314
1315        assert_eq!(result.len(), 1);
1316        assert_eq!(result[0].1.len(), 2);
1317        assert!(result[0].1.contains(&FileId(0)));
1318        assert!(result[0].1.contains(&FileId(1)));
1319    }
1320
1321    #[test]
1322    fn dynamic_patterns_no_matches_returns_empty() {
1323        let from_dir = Path::new("/project/src");
1324        let patterns = vec![DynamicImportPattern {
1325            prefix: "./locales/".into(),
1326            suffix: Some(".json".into()),
1327            span: dummy_span(),
1328        }];
1329        let canonical_paths = vec![PathBuf::from("/project/src/utils.ts")];
1330        let files = vec![DiscoveredFile {
1331            id: FileId(0),
1332            path: PathBuf::from("/project/src/utils.ts"),
1333            size_bytes: 100,
1334        }];
1335
1336        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1337
1338        assert!(result.is_empty());
1339    }
1340
1341    #[test]
1342    fn dynamic_patterns_empty_patterns_list() {
1343        let from_dir = Path::new("/project/src");
1344        let canonical_paths = vec![PathBuf::from("/project/src/utils.ts")];
1345        let files = vec![DiscoveredFile {
1346            id: FileId(0),
1347            path: PathBuf::from("/project/src/utils.ts"),
1348            size_bytes: 100,
1349        }];
1350
1351        let result = resolve_dynamic_patterns(from_dir, &[], &canonical_paths, &files);
1352        assert!(result.is_empty());
1353    }
1354
1355    #[test]
1356    fn dynamic_patterns_glob_prefix_passthrough() {
1357        let from_dir = Path::new("/project/src");
1358        let patterns = vec![DynamicImportPattern {
1359            prefix: "./**/*.ts".into(),
1360            suffix: None,
1361            span: dummy_span(),
1362        }];
1363        let canonical_paths = vec![
1364            PathBuf::from("/project/src/utils.ts"),
1365            PathBuf::from("/project/src/deep/nested.ts"),
1366        ];
1367        let files = vec![
1368            DiscoveredFile {
1369                id: FileId(0),
1370                path: PathBuf::from("/project/src/utils.ts"),
1371                size_bytes: 100,
1372            },
1373            DiscoveredFile {
1374                id: FileId(1),
1375                path: PathBuf::from("/project/src/deep/nested.ts"),
1376                size_bytes: 100,
1377            },
1378        ];
1379
1380        let result = resolve_dynamic_patterns(from_dir, &patterns, &canonical_paths, &files);
1381
1382        assert_eq!(result.len(), 1);
1383        assert_eq!(result[0].1.len(), 2);
1384    }
1385
1386    // -----------------------------------------------------------------------
1387    // Unresolvable specifier handling
1388    // -----------------------------------------------------------------------
1389
1390    #[test]
1391    fn static_import_unresolvable_relative_path() {
1392        with_empty_ctx(|ctx| {
1393            let imports = vec![make_import(
1394                "./nonexistent",
1395                ImportedName::Default,
1396                "missing",
1397            )];
1398            let file = Path::new("/project/src/app.ts");
1399            let result = resolve_static_imports(ctx, file, &imports);
1400
1401            assert_eq!(result.len(), 1);
1402            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1403        });
1404    }
1405
1406    #[test]
1407    fn static_import_bare_specifier_becomes_npm_package() {
1408        with_empty_ctx(|ctx| {
1409            let imports = vec![make_import("react", ImportedName::Default, "React")];
1410            let file = Path::new("/project/src/app.ts");
1411            let result = resolve_static_imports(ctx, file, &imports);
1412
1413            assert_eq!(result.len(), 1);
1414            assert!(matches!(
1415                result[0].target,
1416                ResolveResult::NpmPackage(ref pkg) if pkg == "react"
1417            ));
1418        });
1419    }
1420
1421    #[test]
1422    fn require_bare_specifier_becomes_npm_package() {
1423        with_empty_ctx(|ctx| {
1424            let req = make_require("express", vec![], Some("express"));
1425            let file = Path::new("/project/src/app.js");
1426            let result = resolve_single_require(ctx, file, &req);
1427
1428            assert_eq!(result.len(), 1);
1429            assert!(matches!(
1430                result[0].target,
1431                ResolveResult::NpmPackage(ref pkg) if pkg == "express"
1432            ));
1433        });
1434    }
1435
1436    #[test]
1437    fn dynamic_import_unresolvable() {
1438        with_empty_ctx(|ctx| {
1439            let imp = make_dynamic("./missing-module", vec![], None);
1440            let file = Path::new("/project/src/app.ts");
1441            let result = resolve_single_dynamic_import(ctx, file, &imp);
1442
1443            assert_eq!(result.len(), 1);
1444            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1445        });
1446    }
1447
1448    #[test]
1449    fn re_export_unresolvable() {
1450        with_empty_ctx(|ctx| {
1451            let re_exports = vec![make_re_export("./missing", "foo", "foo")];
1452            let file = Path::new("/project/src/index.ts");
1453            let result = resolve_re_exports(ctx, file, &re_exports);
1454
1455            assert_eq!(result.len(), 1);
1456            assert!(matches!(result[0].target, ResolveResult::Unresolvable(_)));
1457        });
1458    }
1459}