Skip to main content

fallow_core/
resolve.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use dashmap::DashMap;
5use fallow_config::ResolvedConfig;
6use oxc_resolver::{ResolveOptions, Resolver};
7use rayon::prelude::*;
8
9use crate::discover::{DiscoveredFile, FileId};
10use crate::extract::{ImportInfo, ModuleInfo, ReExportInfo};
11
12/// Thread-safe cache for bare specifier resolutions using lock-free concurrent reads.
13/// Bare specifiers (like `react`, `lodash/merge`) resolve to the same target
14/// regardless of which file imports them (modulo nested node_modules, which is rare).
15/// Uses DashMap (sharded read-write locks) instead of Mutex<HashMap> to eliminate
16/// contention under rayon's work-stealing on large projects.
17struct BareSpecifierCache {
18    cache: DashMap<String, ResolveResult>,
19}
20
21impl BareSpecifierCache {
22    fn new() -> Self {
23        Self {
24            cache: DashMap::new(),
25        }
26    }
27
28    fn get(&self, specifier: &str) -> Option<ResolveResult> {
29        self.cache.get(specifier).map(|entry| entry.clone())
30    }
31
32    fn insert(&self, specifier: String, result: ResolveResult) {
33        self.cache.insert(specifier, result);
34    }
35}
36
37/// Result of resolving an import specifier.
38#[derive(Debug, Clone)]
39pub enum ResolveResult {
40    /// Resolved to a file within the project.
41    InternalModule(FileId),
42    /// Resolved to a file outside the project (node_modules, .json, etc.).
43    ExternalFile(PathBuf),
44    /// Bare specifier — an npm package.
45    NpmPackage(String),
46    /// Could not resolve.
47    Unresolvable(String),
48}
49
50/// A resolved import with its target.
51#[derive(Debug, Clone)]
52pub struct ResolvedImport {
53    pub info: ImportInfo,
54    pub target: ResolveResult,
55}
56
57/// A resolved re-export with its target.
58#[derive(Debug, Clone)]
59pub struct ResolvedReExport {
60    pub info: ReExportInfo,
61    pub target: ResolveResult,
62}
63
64/// Fully resolved module with all imports mapped to targets.
65#[derive(Debug)]
66pub struct ResolvedModule {
67    pub file_id: FileId,
68    pub path: PathBuf,
69    pub exports: Vec<crate::extract::ExportInfo>,
70    pub re_exports: Vec<ResolvedReExport>,
71    pub resolved_imports: Vec<ResolvedImport>,
72    pub resolved_dynamic_imports: Vec<ResolvedImport>,
73    pub resolved_dynamic_patterns: Vec<(crate::extract::DynamicImportPattern, Vec<FileId>)>,
74    pub member_accesses: Vec<crate::extract::MemberAccess>,
75    pub whole_object_uses: Vec<String>,
76    pub has_cjs_exports: bool,
77}
78
79/// Resolve all imports across all modules in parallel.
80pub fn resolve_all_imports(
81    modules: &[ModuleInfo],
82    config: &ResolvedConfig,
83    files: &[DiscoveredFile],
84) -> Vec<ResolvedModule> {
85    // Pre-compute canonical paths ONCE for all files (avoiding repeated syscalls)
86    let canonical_paths: Vec<PathBuf> = files
87        .iter()
88        .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
89        .collect();
90
91    // Build path -> FileId index using pre-computed canonical paths
92    let path_to_id: HashMap<&Path, FileId> = canonical_paths
93        .iter()
94        .enumerate()
95        .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
96        .collect();
97
98    // Also index by non-canonical path for fallback lookups
99    let raw_path_to_id: HashMap<&Path, FileId> =
100        files.iter().map(|f| (f.path.as_path(), f.id)).collect();
101
102    let file_id_to_path: HashMap<FileId, &Path> =
103        files.iter().map(|f| (f.id, f.path.as_path())).collect();
104
105    // Create resolver ONCE and share across threads (oxc_resolver::Resolver is Send + Sync)
106    let resolver = create_resolver(config);
107
108    // Cache for bare specifier resolutions (e.g., `react`, `lodash/merge`)
109    let bare_cache = BareSpecifierCache::new();
110
111    // Resolve in parallel — shared resolver instance
112    modules
113        .par_iter()
114        .filter_map(|module| {
115            let file_path = match file_id_to_path.get(&module.file_id) {
116                Some(p) => p,
117                None => {
118                    tracing::warn!(
119                        file_id = module.file_id.0,
120                        "Skipping module with unknown file_id during resolution"
121                    );
122                    return None;
123                }
124            };
125
126            let resolved_imports: Vec<ResolvedImport> = module
127                .imports
128                .iter()
129                .map(|imp| ResolvedImport {
130                    info: imp.clone(),
131                    target: resolve_specifier(
132                        &resolver,
133                        file_path,
134                        &imp.source,
135                        &path_to_id,
136                        &raw_path_to_id,
137                        &bare_cache,
138                    ),
139                })
140                .collect();
141
142            let resolved_dynamic_imports: Vec<ResolvedImport> = module
143                .dynamic_imports
144                .iter()
145                .flat_map(|imp| {
146                    let target = resolve_specifier(
147                        &resolver,
148                        file_path,
149                        &imp.source,
150                        &path_to_id,
151                        &raw_path_to_id,
152                        &bare_cache,
153                    );
154                    if !imp.destructured_names.is_empty() {
155                        // `const { a, b } = await import('./x')` → Named imports
156                        imp.destructured_names
157                            .iter()
158                            .map(|name| ResolvedImport {
159                                info: ImportInfo {
160                                    source: imp.source.clone(),
161                                    imported_name: crate::extract::ImportedName::Named(
162                                        name.clone(),
163                                    ),
164                                    local_name: name.clone(),
165                                    is_type_only: false,
166                                    span: imp.span,
167                                },
168                                target: target.clone(),
169                            })
170                            .collect()
171                    } else if imp.local_name.is_some() {
172                        // `const mod = await import('./x')` → Namespace with local_name
173                        vec![ResolvedImport {
174                            info: ImportInfo {
175                                source: imp.source.clone(),
176                                imported_name: crate::extract::ImportedName::Namespace,
177                                local_name: imp.local_name.clone().unwrap_or_default(),
178                                is_type_only: false,
179                                span: imp.span,
180                            },
181                            target,
182                        }]
183                    } else {
184                        // Side-effect only: `await import('./x')` with no assignment
185                        vec![ResolvedImport {
186                            info: ImportInfo {
187                                source: imp.source.clone(),
188                                imported_name: crate::extract::ImportedName::SideEffect,
189                                local_name: String::new(),
190                                is_type_only: false,
191                                span: imp.span,
192                            },
193                            target,
194                        }]
195                    }
196                })
197                .collect();
198
199            let re_exports: Vec<ResolvedReExport> = module
200                .re_exports
201                .iter()
202                .map(|re| ResolvedReExport {
203                    info: re.clone(),
204                    target: resolve_specifier(
205                        &resolver,
206                        file_path,
207                        &re.source,
208                        &path_to_id,
209                        &raw_path_to_id,
210                        &bare_cache,
211                    ),
212                })
213                .collect();
214
215            // Also resolve require() calls.
216            // Destructured requires → Named imports; others → Namespace (conservative).
217            let require_imports: Vec<ResolvedImport> = module
218                .require_calls
219                .iter()
220                .flat_map(|req| {
221                    let target = resolve_specifier(
222                        &resolver,
223                        file_path,
224                        &req.source,
225                        &path_to_id,
226                        &raw_path_to_id,
227                        &bare_cache,
228                    );
229                    if req.destructured_names.is_empty() {
230                        vec![ResolvedImport {
231                            info: ImportInfo {
232                                source: req.source.clone(),
233                                imported_name: crate::extract::ImportedName::Namespace,
234                                local_name: req.local_name.clone().unwrap_or_default(),
235                                is_type_only: false,
236                                span: req.span,
237                            },
238                            target,
239                        }]
240                    } else {
241                        req.destructured_names
242                            .iter()
243                            .map(|name| ResolvedImport {
244                                info: ImportInfo {
245                                    source: req.source.clone(),
246                                    imported_name: crate::extract::ImportedName::Named(
247                                        name.clone(),
248                                    ),
249                                    local_name: name.clone(),
250                                    is_type_only: false,
251                                    span: req.span,
252                                },
253                                target: target.clone(),
254                            })
255                            .collect()
256                    }
257                })
258                .collect();
259
260            let mut all_imports = resolved_imports;
261            all_imports.extend(require_imports);
262
263            // Resolve dynamic import patterns via glob matching against discovered files.
264            // Use pre-computed canonical paths (no syscalls in inner loop).
265            let from_dir = canonical_paths
266                .get(module.file_id.0 as usize)
267                .and_then(|p| p.parent())
268                .unwrap_or(file_path);
269            let resolved_dynamic_patterns: Vec<(
270                crate::extract::DynamicImportPattern,
271                Vec<FileId>,
272            )> = module
273                .dynamic_import_patterns
274                .iter()
275                .filter_map(|pattern| {
276                    let glob_str = make_glob_from_pattern(pattern);
277                    let matcher = globset::Glob::new(&glob_str)
278                        .ok()
279                        .map(|g| g.compile_matcher())?;
280                    let matched: Vec<FileId> = canonical_paths
281                        .iter()
282                        .enumerate()
283                        .filter(|(_idx, canonical)| {
284                            if let Ok(relative) = canonical.strip_prefix(from_dir) {
285                                let rel_str = format!("./{}", relative.to_string_lossy());
286                                matcher.is_match(&rel_str)
287                            } else {
288                                false
289                            }
290                        })
291                        .map(|(idx, _)| files[idx].id)
292                        .collect();
293                    if matched.is_empty() {
294                        None
295                    } else {
296                        Some((pattern.clone(), matched))
297                    }
298                })
299                .collect();
300
301            Some(ResolvedModule {
302                file_id: module.file_id,
303                path: file_path.to_path_buf(),
304                exports: module.exports.clone(),
305                re_exports,
306                resolved_imports: all_imports,
307                resolved_dynamic_imports,
308                resolved_dynamic_patterns,
309                member_accesses: module.member_accesses.clone(),
310                whole_object_uses: module.whole_object_uses.clone(),
311                has_cjs_exports: module.has_cjs_exports,
312            })
313        })
314        .collect()
315}
316
317/// Create an oxc_resolver instance with standard configuration.
318fn create_resolver(config: &ResolvedConfig) -> Resolver {
319    let mut options = ResolveOptions {
320        extensions: vec![
321            ".ts".into(),
322            ".tsx".into(),
323            ".d.ts".into(),
324            ".d.mts".into(),
325            ".d.cts".into(),
326            ".mts".into(),
327            ".cts".into(),
328            ".js".into(),
329            ".jsx".into(),
330            ".mjs".into(),
331            ".cjs".into(),
332            ".json".into(),
333            ".vue".into(),
334            ".svelte".into(),
335        ],
336        // Support TypeScript's node16/nodenext module resolution where .ts files
337        // are imported with .js extensions (e.g., `import './api.js'` for `api.ts`).
338        extension_alias: vec![
339            (
340                ".js".into(),
341                vec![".ts".into(), ".tsx".into(), ".js".into()],
342            ),
343            (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
344            (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
345            (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
346        ],
347        condition_names: vec![
348            "import".into(),
349            "require".into(),
350            "default".into(),
351            "types".into(),
352            "node".into(),
353        ],
354        main_fields: vec!["module".into(), "main".into()],
355        ..Default::default()
356    };
357
358    // Auto-detect tsconfig.json (check common variants at project root)
359    let tsconfig_candidates = ["tsconfig.json", "tsconfig.app.json", "tsconfig.build.json"];
360    let root_tsconfig = tsconfig_candidates
361        .iter()
362        .map(|name| config.root.join(name))
363        .find(|p| p.exists());
364
365    if let Some(tsconfig) = root_tsconfig {
366        // Use manual config with auto references to also discover workspace tsconfigs
367        options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Manual(
368            oxc_resolver::TsconfigOptions {
369                config_file: tsconfig,
370                references: oxc_resolver::TsconfigReferences::Auto,
371            },
372        ));
373    } else {
374        // No root tsconfig found — use auto-discovery mode so oxc_resolver
375        // can find the nearest tsconfig.json for each file (important for
376        // workspace packages that have their own tsconfig)
377        options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Auto);
378    }
379
380    Resolver::new(options)
381}
382
383/// Resolve a single import specifier to a target.
384fn resolve_specifier(
385    resolver: &Resolver,
386    from_file: &Path,
387    specifier: &str,
388    path_to_id: &HashMap<&Path, FileId>,
389    raw_path_to_id: &HashMap<&Path, FileId>,
390    bare_cache: &BareSpecifierCache,
391) -> ResolveResult {
392    // URL imports (https://, http://, data:) are valid but can't be resolved locally
393    if specifier.contains("://") || specifier.starts_with("data:") {
394        return ResolveResult::ExternalFile(PathBuf::from(specifier));
395    }
396
397    // Fast path for bare specifiers: check cache first to avoid repeated resolver work
398    let is_bare = is_bare_specifier(specifier);
399    if is_bare && let Some(cached) = bare_cache.get(specifier) {
400        return cached;
401    }
402
403    let dir = from_file.parent().unwrap_or(from_file);
404
405    let result = match resolver.resolve(dir, specifier) {
406        Ok(resolved) => {
407            let resolved_path = resolved.path();
408            // Try raw path lookup first (avoids canonicalize syscall in most cases)
409            if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
410                return ResolveResult::InternalModule(file_id);
411            }
412            // Fall back to canonical path lookup
413            match resolved_path.canonicalize() {
414                Ok(canonical) => {
415                    if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
416                        ResolveResult::InternalModule(file_id)
417                    } else if let Some(pkg_name) =
418                        extract_package_name_from_node_modules_path(&canonical)
419                    {
420                        ResolveResult::NpmPackage(pkg_name)
421                    } else {
422                        ResolveResult::ExternalFile(canonical)
423                    }
424                }
425                Err(_) => {
426                    if let Some(pkg_name) =
427                        extract_package_name_from_node_modules_path(resolved_path)
428                    {
429                        ResolveResult::NpmPackage(pkg_name)
430                    } else {
431                        ResolveResult::ExternalFile(resolved_path.to_path_buf())
432                    }
433                }
434            }
435        }
436        Err(_) => {
437            if is_bare {
438                let pkg_name = extract_package_name(specifier);
439                ResolveResult::NpmPackage(pkg_name)
440            } else {
441                ResolveResult::Unresolvable(specifier.to_string())
442            }
443        }
444    };
445
446    // Cache bare specifier results (NpmPackage or failed resolutions) for reuse
447    if is_bare {
448        bare_cache.insert(specifier.to_string(), result.clone());
449    }
450
451    result
452}
453
454/// Extract npm package name from a resolved path inside `node_modules`.
455///
456/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
457/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
458/// Returns `None` if the path doesn't contain a `node_modules` segment.
459fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
460    let components: Vec<&str> = path
461        .components()
462        .filter_map(|c| match c {
463            std::path::Component::Normal(s) => s.to_str(),
464            _ => None,
465        })
466        .collect();
467
468    // Find the last "node_modules" component (handles nested node_modules)
469    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
470
471    let after = &components[nm_idx + 1..];
472    if after.is_empty() {
473        return None;
474    }
475
476    if after[0].starts_with('@') {
477        // Scoped package: @scope/pkg
478        if after.len() >= 2 {
479            Some(format!("{}/{}", after[0], after[1]))
480        } else {
481            Some(after[0].to_string())
482        }
483    } else {
484        Some(after[0].to_string())
485    }
486}
487
488/// Convert a `DynamicImportPattern` to a glob string for file matching.
489fn make_glob_from_pattern(pattern: &crate::extract::DynamicImportPattern) -> String {
490    // If the prefix already contains glob characters (from import.meta.glob), use as-is
491    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
492        return pattern.prefix.clone();
493    }
494    match &pattern.suffix {
495        Some(suffix) => format!("{}*{}", pattern.prefix, suffix),
496        None => format!("{}*", pattern.prefix),
497    }
498}
499
500/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
501fn is_bare_specifier(specifier: &str) -> bool {
502    !specifier.starts_with('.')
503        && !specifier.starts_with('/')
504        && !specifier.contains("://")
505        && !specifier.starts_with("data:")
506}
507
508/// Extract the npm package name from a specifier.
509/// `@scope/pkg/foo/bar` -> `@scope/pkg`
510/// `lodash/merge` -> `lodash`
511pub fn extract_package_name(specifier: &str) -> String {
512    if specifier.starts_with('@') {
513        let parts: Vec<&str> = specifier.splitn(3, '/').collect();
514        if parts.len() >= 2 {
515            format!("{}/{}", parts[0], parts[1])
516        } else {
517            specifier.to_string()
518        }
519    } else {
520        specifier.split('/').next().unwrap_or(specifier).to_string()
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_extract_package_name() {
530        assert_eq!(extract_package_name("react"), "react");
531        assert_eq!(extract_package_name("lodash/merge"), "lodash");
532        assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
533        assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
534    }
535
536    #[test]
537    fn test_is_bare_specifier() {
538        assert!(is_bare_specifier("react"));
539        assert!(is_bare_specifier("@scope/pkg"));
540        assert!(is_bare_specifier("#internal/module"));
541        assert!(!is_bare_specifier("./utils"));
542        assert!(!is_bare_specifier("../lib"));
543        assert!(!is_bare_specifier("/absolute"));
544    }
545
546    #[test]
547    fn test_extract_package_name_from_node_modules_path_regular() {
548        let path = PathBuf::from("/project/node_modules/react/index.js");
549        assert_eq!(
550            extract_package_name_from_node_modules_path(&path),
551            Some("react".to_string())
552        );
553    }
554
555    #[test]
556    fn test_extract_package_name_from_node_modules_path_scoped() {
557        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
558        assert_eq!(
559            extract_package_name_from_node_modules_path(&path),
560            Some("@babel/core".to_string())
561        );
562    }
563
564    #[test]
565    fn test_extract_package_name_from_node_modules_path_nested() {
566        // Nested node_modules: should use the last (innermost) one
567        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
568        assert_eq!(
569            extract_package_name_from_node_modules_path(&path),
570            Some("pkg-b".to_string())
571        );
572    }
573
574    #[test]
575    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
576        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
577        assert_eq!(
578            extract_package_name_from_node_modules_path(&path),
579            Some("react-dom".to_string())
580        );
581    }
582
583    #[test]
584    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
585        let path = PathBuf::from("/project/src/components/Button.tsx");
586        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
587    }
588
589    #[test]
590    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
591        let path = PathBuf::from("/project/node_modules");
592        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
593    }
594
595    #[test]
596    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
597        // Edge case: path ends at scope without package name
598        let path = PathBuf::from("/project/node_modules/@scope");
599        assert_eq!(
600            extract_package_name_from_node_modules_path(&path),
601            Some("@scope".to_string())
602        );
603    }
604
605    #[test]
606    fn test_resolve_specifier_node_modules_returns_npm_package() {
607        // When oxc_resolver resolves to a node_modules path that is NOT in path_to_id,
608        // it should return NpmPackage instead of ExternalFile.
609        // We can't easily test resolve_specifier directly without a real resolver,
610        // but the extract_package_name_from_node_modules_path function covers the
611        // core logic that was missing.
612        let path =
613            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
614        assert_eq!(
615            extract_package_name_from_node_modules_path(&path),
616            Some("styled-components".to_string())
617        );
618
619        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
620        assert_eq!(
621            extract_package_name_from_node_modules_path(&path),
622            Some("next".to_string())
623        );
624    }
625}