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 fallow_types::discover::{DiscoveredFile, FileId};
23use fallow_types::extract::{ImportInfo, ModuleInfo};
24
25use fallbacks::make_glob_from_pattern;
26use specifier::{create_resolver, resolve_specifier};
27use types::ResolveContext;
28
29/// Resolve all imports across all modules in parallel.
30pub fn resolve_all_imports(
31    modules: &[ModuleInfo],
32    files: &[DiscoveredFile],
33    workspaces: &[fallow_config::WorkspaceInfo],
34    active_plugins: &[String],
35    path_aliases: &[(String, String)],
36    root: &Path,
37) -> Vec<ResolvedModule> {
38    // Build workspace name → root index for pnpm store fallback.
39    // Canonicalize roots to match path_to_id (which uses canonical paths).
40    // Without this, macOS /var → /private/var and similar platform symlinks
41    // cause workspace roots to mismatch canonical file paths.
42    let canonical_ws_roots: Vec<PathBuf> = workspaces
43        .par_iter()
44        .map(|ws| ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone()))
45        .collect();
46    let workspace_roots: FxHashMap<&str, &Path> = workspaces
47        .iter()
48        .zip(canonical_ws_roots.iter())
49        .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
50        .collect();
51
52    // Pre-compute canonical paths ONCE for all files in parallel (avoiding repeated syscalls).
53    // Each canonicalize() is a syscall — parallelizing over rayon reduces wall time.
54    let canonical_paths: Vec<PathBuf> = files
55        .par_iter()
56        .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
57        .collect();
58
59    // Build path -> FileId index using pre-computed canonical paths
60    let path_to_id: FxHashMap<&Path, FileId> = canonical_paths
61        .iter()
62        .enumerate()
63        .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
64        .collect();
65
66    // Also index by non-canonical path for fallback lookups
67    let raw_path_to_id: FxHashMap<&Path, FileId> =
68        files.iter().map(|f| (f.path.as_path(), f.id)).collect();
69
70    // FileIds are sequential 0..n, so direct array indexing is faster than FxHashMap.
71    let file_paths: Vec<&Path> = files.iter().map(|f| f.path.as_path()).collect();
72
73    // Create resolver ONCE and share across threads (oxc_resolver::Resolver is Send + Sync)
74    let resolver = create_resolver(active_plugins);
75
76    // Shared resolution context — avoids passing 6 arguments to every resolve_specifier call
77    let ctx = ResolveContext {
78        resolver: &resolver,
79        path_to_id: &path_to_id,
80        raw_path_to_id: &raw_path_to_id,
81        workspace_roots: &workspace_roots,
82        path_aliases,
83        root,
84    };
85
86    // Resolve in parallel — shared resolver instance.
87    // Each file resolves its own imports independently (no shared bare specifier cache).
88    // oxc_resolver's internal caches (package.json, tsconfig, directory entries) are
89    // shared across threads for performance.
90    let mut resolved: Vec<ResolvedModule> = modules
91        .par_iter()
92        .filter_map(|module| {
93            let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
94                tracing::warn!(
95                    file_id = module.file_id.0,
96                    "Skipping module with unknown file_id during resolution"
97                );
98                return None;
99            };
100
101            let resolved_imports: Vec<ResolvedImport> = module
102                .imports
103                .iter()
104                .map(|imp| ResolvedImport {
105                    info: imp.clone(),
106                    target: resolve_specifier(&ctx, file_path, &imp.source),
107                })
108                .collect();
109
110            let resolved_dynamic_imports: Vec<ResolvedImport> = module
111                .dynamic_imports
112                .iter()
113                .flat_map(|imp| {
114                    let target = resolve_specifier(&ctx, file_path, &imp.source);
115                    if !imp.destructured_names.is_empty() {
116                        // `const { a, b } = await import('./x')` → Named imports
117                        imp.destructured_names
118                            .iter()
119                            .map(|name| ResolvedImport {
120                                info: ImportInfo {
121                                    source: imp.source.clone(),
122                                    imported_name: fallow_types::extract::ImportedName::Named(
123                                        name.clone(),
124                                    ),
125                                    local_name: name.clone(),
126                                    is_type_only: false,
127                                    span: imp.span,
128                                },
129                                target: target.clone(),
130                            })
131                            .collect()
132                    } else if imp.local_name.is_some() {
133                        // `const mod = await import('./x')` → Namespace with local_name
134                        vec![ResolvedImport {
135                            info: ImportInfo {
136                                source: imp.source.clone(),
137                                imported_name: fallow_types::extract::ImportedName::Namespace,
138                                local_name: imp.local_name.clone().unwrap_or_default(),
139                                is_type_only: false,
140                                span: imp.span,
141                            },
142                            target,
143                        }]
144                    } else {
145                        // Side-effect only: `await import('./x')` with no assignment
146                        vec![ResolvedImport {
147                            info: ImportInfo {
148                                source: imp.source.clone(),
149                                imported_name: fallow_types::extract::ImportedName::SideEffect,
150                                local_name: String::new(),
151                                is_type_only: false,
152                                span: imp.span,
153                            },
154                            target,
155                        }]
156                    }
157                })
158                .collect();
159
160            let re_exports: Vec<ResolvedReExport> = module
161                .re_exports
162                .iter()
163                .map(|re| ResolvedReExport {
164                    info: re.clone(),
165                    target: resolve_specifier(&ctx, file_path, &re.source),
166                })
167                .collect();
168
169            // Also resolve require() calls.
170            // Destructured requires → Named imports; others → Namespace (conservative).
171            let require_imports: Vec<ResolvedImport> = module
172                .require_calls
173                .iter()
174                .flat_map(|req| {
175                    let target = resolve_specifier(&ctx, file_path, &req.source);
176                    if req.destructured_names.is_empty() {
177                        vec![ResolvedImport {
178                            info: ImportInfo {
179                                source: req.source.clone(),
180                                imported_name: fallow_types::extract::ImportedName::Namespace,
181                                local_name: req.local_name.clone().unwrap_or_default(),
182                                is_type_only: false,
183                                span: req.span,
184                            },
185                            target,
186                        }]
187                    } else {
188                        req.destructured_names
189                            .iter()
190                            .map(|name| ResolvedImport {
191                                info: ImportInfo {
192                                    source: req.source.clone(),
193                                    imported_name: fallow_types::extract::ImportedName::Named(
194                                        name.clone(),
195                                    ),
196                                    local_name: name.clone(),
197                                    is_type_only: false,
198                                    span: req.span,
199                                },
200                                target: target.clone(),
201                            })
202                            .collect()
203                    }
204                })
205                .collect();
206
207            let mut all_imports = resolved_imports;
208            all_imports.extend(require_imports);
209
210            // Resolve dynamic import patterns via glob matching against discovered files.
211            // Use pre-computed canonical paths (no syscalls in inner loop).
212            let from_dir = canonical_paths
213                .get(module.file_id.0 as usize)
214                .and_then(|p| p.parent())
215                .unwrap_or(file_path);
216            let resolved_dynamic_patterns: Vec<(
217                fallow_types::extract::DynamicImportPattern,
218                Vec<FileId>,
219            )> = module
220                .dynamic_import_patterns
221                .iter()
222                .filter_map(|pattern| {
223                    let glob_str = make_glob_from_pattern(pattern);
224                    let matcher = globset::Glob::new(&glob_str)
225                        .ok()
226                        .map(|g| g.compile_matcher())?;
227                    let matched: Vec<FileId> = canonical_paths
228                        .iter()
229                        .enumerate()
230                        .filter(|(_idx, canonical)| {
231                            canonical.strip_prefix(from_dir).is_ok_and(|relative| {
232                                let rel_str = format!("./{}", relative.to_string_lossy());
233                                matcher.is_match(&rel_str)
234                            })
235                        })
236                        .map(|(idx, _)| files[idx].id)
237                        .collect();
238                    if matched.is_empty() {
239                        None
240                    } else {
241                        Some((pattern.clone(), matched))
242                    }
243                })
244                .collect();
245
246            Some(ResolvedModule {
247                file_id: module.file_id,
248                path: file_path.to_path_buf(),
249                exports: module.exports.clone(),
250                re_exports,
251                resolved_imports: all_imports,
252                resolved_dynamic_imports,
253                resolved_dynamic_patterns,
254                member_accesses: module.member_accesses.clone(),
255                whole_object_uses: module.whole_object_uses.clone(),
256                has_cjs_exports: module.has_cjs_exports,
257                unused_import_bindings: module.unused_import_bindings.clone(),
258            })
259        })
260        .collect();
261
262    // Post-resolution pass: deterministic specifier upgrade.
263    //
264    // With TsconfigDiscovery::Auto, the same bare specifier (e.g., `preact/hooks`)
265    // may resolve to InternalModule from files under a tsconfig with path aliases
266    // but NpmPackage from files without such aliases. The parallel resolution cache
267    // makes the per-file result depend on which thread resolved first (non-deterministic).
268    //
269    // To fix this: scan all resolved imports/re-exports to find bare specifiers where
270    // ANY file resolved to InternalModule. For those specifiers, upgrade all NpmPackage
271    // results to InternalModule. This is correct because if any tsconfig context maps
272    // a specifier to a project source file, that source file IS the origin of the
273    // package — all imports of that specifier reference the same source.
274    //
275    // Note: if two tsconfigs map the same specifier to different FileIds, the first
276    // one encountered (by module order = FileId order) wins. This is deterministic
277    // but may be imprecise for that edge case — both files get connected regardless.
278    let mut specifier_upgrades: FxHashMap<String, FileId> = FxHashMap::default();
279    for module in &resolved {
280        for imp in module
281            .resolved_imports
282            .iter()
283            .chain(module.resolved_dynamic_imports.iter())
284        {
285            if is_bare_specifier(&imp.info.source)
286                && let ResolveResult::InternalModule(file_id) = &imp.target
287            {
288                specifier_upgrades
289                    .entry(imp.info.source.clone())
290                    .or_insert(*file_id);
291            }
292        }
293        for re in &module.re_exports {
294            if is_bare_specifier(&re.info.source)
295                && let ResolveResult::InternalModule(file_id) = &re.target
296            {
297                specifier_upgrades
298                    .entry(re.info.source.clone())
299                    .or_insert(*file_id);
300            }
301        }
302    }
303
304    if specifier_upgrades.is_empty() {
305        return resolved;
306    }
307
308    // Apply upgrades: replace NpmPackage with InternalModule for matched specifiers
309    for module in &mut resolved {
310        for imp in module
311            .resolved_imports
312            .iter_mut()
313            .chain(module.resolved_dynamic_imports.iter_mut())
314        {
315            if matches!(imp.target, ResolveResult::NpmPackage(_))
316                && let Some(&file_id) = specifier_upgrades.get(&imp.info.source)
317            {
318                imp.target = ResolveResult::InternalModule(file_id);
319            }
320        }
321        for re in &mut module.re_exports {
322            if matches!(re.target, ResolveResult::NpmPackage(_))
323                && let Some(&file_id) = specifier_upgrades.get(&re.info.source)
324            {
325                re.target = ResolveResult::InternalModule(file_id);
326            }
327        }
328    }
329
330    resolved
331}