Skip to main content

fallow_graph/resolve/
mod.rs

1//! Import specifier resolution using `oxc_resolver`.
2//!
3//! Orchestrates the resolution pipeline: for every extracted module, resolves all
4//! import specifiers in parallel (via rayon) to an [`ResolveResult`], internal file,
5//! npm package, external file, or unresolvable. The entry point is [`resolve_all_imports`].
6//!
7//! Resolution is split into submodules by import kind:
8//! - `static_imports`: ES `import` declarations
9//! - `dynamic_imports`: `import()` expressions and glob-based dynamic patterns
10//! - `require_imports`: CommonJS `require()` calls
11//! - `re_exports`: `export { x } from './y'` re-export sources
12//! - `upgrades`: post-resolution pass fixing non-deterministic bare specifier results
13//!
14//! Handles tsconfig path aliases (auto-discovered per file), pnpm virtual store paths,
15//! React Native platform extensions, and package.json `exports` subpath resolution with
16//! output-to-source directory fallback.
17
18mod dynamic_imports;
19pub(crate) mod fallbacks;
20mod path_info;
21mod re_exports;
22mod react_native;
23mod require_imports;
24mod specifier;
25mod static_imports;
26#[cfg(test)]
27mod tests;
28mod types;
29mod upgrades;
30
31pub use fallbacks::extract_package_name_from_node_modules_path;
32pub use path_info::{
33    extract_package_name, is_bare_specifier, is_path_alias, is_valid_package_name,
34};
35pub use types::{
36    ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport, ResolvedSourceEdge,
37};
38
39use std::path::{Path, PathBuf};
40use std::sync::Mutex;
41
42use rayon::prelude::*;
43use rustc_hash::{FxHashMap, FxHashSet};
44
45use fallow_config::{AutoImportKind, AutoImportRule};
46use fallow_types::discover::{DiscoveredFile, FileId};
47use fallow_types::extract::{ImportInfo, ImportedName, ModuleInfo};
48use oxc_span::Span;
49
50use dynamic_imports::{resolve_dynamic_imports, resolve_dynamic_patterns};
51use re_exports::resolve_re_exports;
52use react_native::{build_condition_names, build_extensions};
53use require_imports::resolve_require_imports;
54use specifier::create_resolver;
55use static_imports::resolve_static_imports;
56use types::{PackageManifestInfo, ResolveContext};
57use upgrades::apply_specifier_upgrades;
58
59/// Inputs used to resolve imports for a complete extracted project.
60pub struct ResolveAllImportsInput<'a> {
61    /// Extracted modules whose imports should be resolved.
62    pub modules: &'a [ModuleInfo],
63    /// Discovered source files indexed by [`FileId`].
64    pub files: &'a [DiscoveredFile],
65    /// Workspace package roots used for package self-resolution.
66    pub workspaces: &'a [fallow_config::WorkspaceInfo],
67    /// Active plugin names that affect extensions and resolver conditions.
68    pub active_plugins: &'a [String],
69    /// Configured TypeScript path alias pairs.
70    pub path_aliases: &'a [(String, String)],
71    /// Auto-import rules that synthesize implicit graph edges.
72    pub auto_imports: &'a [AutoImportRule],
73    /// Additional Sass and SCSS include directories.
74    pub scss_include_paths: &'a [PathBuf],
75    /// Static directory mappings for framework-specific asset resolution.
76    pub static_dir_mappings: &'a [(PathBuf, String)],
77    /// Project root used for package manifest and relative-path resolution.
78    pub root: &'a Path,
79    /// Extra resolver conditions supplied by configuration.
80    pub extra_conditions: &'a [String],
81}
82
83/// Resolve all imports across all modules in parallel.
84#[must_use]
85pub fn resolve_all_imports(input: &ResolveAllImportsInput<'_>) -> Vec<ResolvedModule> {
86    let canonical_ws_roots: Vec<PathBuf> = input
87        .workspaces
88        .par_iter()
89        .map(|ws| dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone()))
90        .collect();
91    let workspace_roots: FxHashMap<&str, &Path> = input
92        .workspaces
93        .iter()
94        .zip(canonical_ws_roots.iter())
95        .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
96        .collect();
97    let root_canonical =
98        dunce::canonicalize(input.root).unwrap_or_else(|_| input.root.to_path_buf());
99    let mut package_manifests = Vec::new();
100    if let Ok(package_json) = fallow_config::PackageJson::load(&input.root.join("package.json")) {
101        package_manifests.push(PackageManifestInfo {
102            root: input.root.to_path_buf(),
103            canonical_root: root_canonical,
104            name: package_json.name.clone(),
105            package_json,
106        });
107    }
108    for (ws, canonical_root) in input.workspaces.iter().zip(canonical_ws_roots.iter()) {
109        if let Ok(package_json) = fallow_config::PackageJson::load(&ws.root.join("package.json")) {
110            package_manifests.push(PackageManifestInfo {
111                root: ws.root.clone(),
112                canonical_root: canonical_root.clone(),
113                name: package_json.name.clone().or_else(|| Some(ws.name.clone())),
114                package_json,
115            });
116        }
117    }
118
119    let root_is_canonical = dunce::canonicalize(input.root).is_ok_and(|c| c == input.root);
120
121    let canonical_paths: Vec<PathBuf> = if root_is_canonical {
122        Vec::new()
123    } else {
124        input
125            .files
126            .par_iter()
127            .map(|f| dunce::canonicalize(&f.path).unwrap_or_else(|_| f.path.clone()))
128            .collect()
129    };
130
131    let path_to_id: FxHashMap<&Path, FileId> = if root_is_canonical {
132        input
133            .files
134            .iter()
135            .map(|f| (f.path.as_path(), f.id))
136            .collect()
137    } else {
138        canonical_paths
139            .iter()
140            .enumerate()
141            .map(|(idx, canonical)| (canonical.as_path(), input.files[idx].id))
142            .collect()
143    };
144
145    let raw_path_to_id: FxHashMap<&Path, FileId> = input
146        .files
147        .iter()
148        .map(|f| (f.path.as_path(), f.id))
149        .collect();
150
151    let file_paths: Vec<&Path> = input.files.iter().map(|f| f.path.as_path()).collect();
152
153    let extensions = build_extensions(input.active_plugins);
154    let condition_names = build_condition_names(input.active_plugins, input.extra_conditions);
155    let resolver = create_resolver(input.active_plugins, input.extra_conditions);
156    let mut style_conditions = input.extra_conditions.to_vec();
157    style_conditions.push("sass".to_string());
158    style_conditions.push("style".to_string());
159    let style_resolver = create_resolver(input.active_plugins, &style_conditions);
160
161    let canonical_fallback = if root_is_canonical {
162        Some(types::CanonicalFallback::new(input.files))
163    } else {
164        None
165    };
166
167    let tsconfig_warned: Mutex<FxHashSet<String>> = Mutex::new(FxHashSet::default());
168
169    let ctx = ResolveContext {
170        resolver: &resolver,
171        style_resolver: &style_resolver,
172        extensions: &extensions,
173        path_to_id: &path_to_id,
174        raw_path_to_id: &raw_path_to_id,
175        workspace_roots: &workspace_roots,
176        package_manifests: &package_manifests,
177        condition_names: &condition_names,
178        path_aliases: input.path_aliases,
179        scss_include_paths: input.scss_include_paths,
180        static_dir_mappings: input.static_dir_mappings,
181        root: input.root,
182        canonical_fallback: canonical_fallback.as_ref(),
183        tsconfig_warned: &tsconfig_warned,
184    };
185
186    let mut resolved: Vec<ResolvedModule> = input
187        .modules
188        .par_iter()
189        .filter_map(|module| {
190            resolve_module_imports(module, &ctx, &file_paths, &canonical_paths, input.files)
191        })
192        .collect();
193
194    apply_specifier_upgrades(&mut resolved);
195
196    synthesize_auto_import_edges(
197        &mut resolved,
198        input.modules,
199        input.auto_imports,
200        &path_to_id,
201        &raw_path_to_id,
202    );
203
204    resolved
205}
206
207fn resolve_module_imports(
208    module: &ModuleInfo,
209    ctx: &ResolveContext<'_>,
210    file_paths: &[&Path],
211    canonical_paths: &[PathBuf],
212    files: &[DiscoveredFile],
213) -> Option<ResolvedModule> {
214    let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
215        tracing::warn!(
216            file_id = module.file_id.0,
217            "Skipping module with unknown file_id during resolution"
218        );
219        return None;
220    };
221
222    let mut all_imports = resolve_static_imports(ctx, file_path, &module.imports);
223    all_imports.extend(resolve_require_imports(
224        ctx,
225        file_path,
226        &module.require_calls,
227    ));
228
229    let from_dir = if canonical_paths.is_empty() {
230        file_path.parent().unwrap_or(file_path)
231    } else {
232        canonical_paths
233            .get(module.file_id.0 as usize)
234            .and_then(|p| p.parent())
235            .unwrap_or(file_path)
236    };
237
238    Some(build_resolved_module(
239        module,
240        ctx,
241        file_path,
242        from_dir,
243        canonical_paths,
244        files,
245        all_imports,
246    ))
247}
248
249fn build_resolved_module(
250    module: &ModuleInfo,
251    ctx: &ResolveContext<'_>,
252    file_path: &Path,
253    from_dir: &Path,
254    canonical_paths: &[PathBuf],
255    files: &[DiscoveredFile],
256    all_imports: Vec<types::ResolvedImport>,
257) -> ResolvedModule {
258    ResolvedModule {
259        file_id: module.file_id,
260        path: file_path.to_path_buf(),
261        exports: module.exports.clone(),
262        re_exports: resolve_re_exports(ctx, file_path, &module.re_exports),
263        resolved_imports: all_imports,
264        resolved_dynamic_imports: resolve_dynamic_imports(ctx, file_path, &module.dynamic_imports),
265        resolved_dynamic_patterns: resolve_dynamic_patterns(
266            from_dir,
267            &module.dynamic_import_patterns,
268            canonical_paths,
269            files,
270        ),
271        member_accesses: module.member_accesses.clone(),
272        whole_object_uses: module.whole_object_uses.clone(),
273        has_cjs_exports: module.has_cjs_exports,
274        has_angular_component_template_url: module.has_angular_component_template_url,
275        unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
276        type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
277        value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
278        namespace_object_aliases: module.namespace_object_aliases.clone(),
279    }
280}
281
282/// Synthesize module-graph edges for convention auto-imports.
283///
284/// For each module, every captured `auto_import_candidates` name is matched
285/// against the active plugins' auto-import table; on a hit a synthetic
286/// [`ResolvedImport`] is added so the existing graph builder credits the edge.
287/// Name collisions across files over-credit every match, keeping each provider
288/// reachable. Resolution is recomputed from the live file index each run.
289fn synthesize_auto_import_edges(
290    resolved: &mut [ResolvedModule],
291    modules: &[ModuleInfo],
292    auto_imports: &[AutoImportRule],
293    path_to_id: &FxHashMap<&Path, FileId>,
294    raw_path_to_id: &FxHashMap<&Path, FileId>,
295) {
296    if auto_imports.is_empty() {
297        return;
298    }
299
300    let mut table: FxHashMap<&str, Vec<(FileId, AutoImportKind)>> = FxHashMap::default();
301    for rule in auto_imports {
302        let source = rule.source.as_path();
303        let Some(file_id) = raw_path_to_id
304            .get(source)
305            .or_else(|| path_to_id.get(source))
306            .copied()
307        else {
308            continue;
309        };
310        table
311            .entry(rule.name.as_str())
312            .or_default()
313            .push((file_id, rule.kind));
314    }
315    if table.is_empty() {
316        return;
317    }
318
319    let candidates: FxHashMap<FileId, &[String]> = modules
320        .iter()
321        .filter(|module| !module.auto_import_candidates.is_empty())
322        .map(|module| (module.file_id, module.auto_import_candidates.as_slice()))
323        .collect();
324    if candidates.is_empty() {
325        return;
326    }
327
328    for module in resolved.iter_mut() {
329        let Some(names) = candidates.get(&module.file_id) else {
330            continue;
331        };
332        for name in *names {
333            if is_auto_import_builtin(name) {
334                continue;
335            }
336            let Some(targets) = table.get(name.as_str()) else {
337                continue;
338            };
339            for (target_id, kind) in targets {
340                if *target_id == module.file_id {
341                    continue;
342                }
343                module.resolved_imports.push(ResolvedImport {
344                    info: synthetic_auto_import_info(name, *kind),
345                    target: ResolveResult::InternalModule(*target_id),
346                });
347            }
348        }
349    }
350}
351
352fn is_auto_import_builtin(name: &str) -> bool {
353    is_js_auto_import_builtin(name)
354        || is_vue_auto_import_builtin(name)
355        || is_nuxt_auto_import_builtin(name)
356}
357
358fn is_js_auto_import_builtin(name: &str) -> bool {
359    matches!(
360        name,
361        "AbortController"
362            | "AbortSignal"
363            | "Array"
364            | "ArrayBuffer"
365            | "BigInt"
366            | "Blob"
367            | "Boolean"
368            | "Buffer"
369            | "CSS"
370            | "DOMParser"
371            | "Date"
372            | "Document"
373            | "Error"
374            | "Event"
375            | "EventTarget"
376            | "File"
377            | "FormData"
378            | "Intl"
379            | "JSON"
380            | "Map"
381            | "Math"
382            | "Number"
383            | "Object"
384            | "Promise"
385            | "Reflect"
386            | "RegExp"
387            | "Response"
388            | "Set"
389            | "String"
390            | "Symbol"
391            | "URL"
392            | "URLSearchParams"
393            | "WeakMap"
394            | "WeakSet"
395            | "Window"
396            | "alert"
397            | "clearInterval"
398            | "clearTimeout"
399            | "console"
400            | "document"
401            | "fetch"
402            | "global"
403            | "globalThis"
404            | "localStorage"
405            | "navigator"
406            | "process"
407            | "requestAnimationFrame"
408            | "sessionStorage"
409            | "setInterval"
410            | "setTimeout"
411            | "window"
412    )
413}
414
415fn is_vue_auto_import_builtin(name: &str) -> bool {
416    matches!(name, |"computed"| "customRef"
417        | "defineAsyncComponent"
418        | "defineComponent"
419        | "effectScope"
420        | "getCurrentInstance"
421        | "h"
422        | "inject"
423        | "isProxy"
424        | "isReactive"
425        | "isReadonly"
426        | "isRef"
427        | "markRaw"
428        | "nextTick"
429        | "onActivated"
430        | "onBeforeMount"
431        | "onBeforeUnmount"
432        | "onBeforeUpdate"
433        | "onDeactivated"
434        | "onErrorCaptured"
435        | "onMounted"
436        | "onRenderTracked"
437        | "onRenderTriggered"
438        | "onScopeDispose"
439        | "onServerPrefetch"
440        | "onUnmounted"
441        | "onUpdated"
442        | "provide"
443        | "reactive"
444        | "readonly"
445        | "ref"
446        | "resolveComponent"
447        | "shallowReactive"
448        | "shallowReadonly"
449        | "shallowRef"
450        | "toRaw"
451        | "toRef"
452        | "toRefs"
453        | "triggerRef"
454        | "unref"
455        | "watch"
456        | "watchEffect"
457        | "watchPostEffect"
458        | "watchSyncEffect")
459}
460
461fn is_nuxt_auto_import_builtin(name: &str) -> bool {
462    matches!(name, |"useAsyncData"| "useCookie"
463        | "useError"
464        | "useFetch"
465        | "useHead"
466        | "useLazyAsyncData"
467        | "useLazyFetch"
468        | "useNuxtApp"
469        | "useRequestEvent"
470        | "useRequestHeaders"
471        | "useRoute"
472        | "useRouter"
473        | "useRuntimeConfig"
474        | "useSeoMeta"
475        | "useState")
476}
477
478/// Build a synthetic [`ImportInfo`] for a convention auto-import. Component and
479/// default kinds credit the default export; named kinds credit the named export.
480fn synthetic_auto_import_info(name: &str, kind: AutoImportKind) -> ImportInfo {
481    let imported_name = match kind {
482        AutoImportKind::Named => ImportedName::Named(name.to_string()),
483        AutoImportKind::Default | AutoImportKind::DefaultComponent => ImportedName::Default,
484    };
485    ImportInfo {
486        source: format!("<auto-import:{name}>"),
487        imported_name,
488        local_name: name.to_string(),
489        is_type_only: false,
490        from_style: false,
491        span: Span::default(),
492        source_span: Span::default(),
493    }
494}