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