Skip to main content

fallow_core/
resolve.rs

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