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/// Reusable per-project resolver state: the resolver instances, package
84/// manifests, and workspace canonicalization that do not depend on the specific
85/// modules being resolved.
86///
87/// Building these is the bulk of `resolve_all_imports`'s non-parallel setup cost
88/// (workspace `dunce::canonicalize`, root + workspace `package.json` loads, and
89/// resolver construction). A caller that resolves many small inputs against the
90/// same project (the external-stylesheet scanner resolves dozens of node_modules
91/// stylesheets one file at a time) builds a session once and reuses it across
92/// every resolution instead of rebuilding this state per call.
93pub struct ResolverSession {
94    resolver: oxc_resolver::Resolver,
95    style_resolver: oxc_resolver::Resolver,
96    extensions: Vec<String>,
97    condition_names: Vec<String>,
98    package_manifests: Vec<PackageManifestInfo>,
99    canonical_ws_roots: Vec<PathBuf>,
100    root_is_canonical: bool,
101}
102
103impl ResolverSession {
104    /// Build the reusable resolver state for `input`'s project context (root,
105    /// workspaces, active plugins, and resolver conditions).
106    ///
107    /// The session is valid for any later [`resolve_all_imports_with_session`]
108    /// call whose input shares that project context; in particular the
109    /// `workspaces` slice must be the same (same entries and order) so workspace
110    /// names line up with the cached `canonical_ws_roots`.
111    #[must_use]
112    pub fn new(input: &ResolveAllImportsInput<'_>) -> Self {
113        let canonical_ws_roots: Vec<PathBuf> = input
114            .workspaces
115            .par_iter()
116            .map(|ws| dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone()))
117            .collect();
118        let package_manifests = build_package_manifests(input, &canonical_ws_roots);
119        let root_is_canonical = dunce::canonicalize(input.root).is_ok_and(|c| c == input.root);
120
121        let extensions = build_extensions(input.active_plugins);
122        let condition_names = build_condition_names(input.active_plugins, input.extra_conditions);
123        let resolver = create_resolver(input.active_plugins, input.extra_conditions);
124        let mut style_conditions = input.extra_conditions.to_vec();
125        style_conditions.push("sass".to_string());
126        style_conditions.push("style".to_string());
127        let style_resolver = create_resolver(input.active_plugins, &style_conditions);
128
129        Self {
130            resolver,
131            style_resolver,
132            extensions,
133            condition_names,
134            package_manifests,
135            canonical_ws_roots,
136            root_is_canonical,
137        }
138    }
139}
140
141/// Resolve all imports across all modules in parallel.
142#[must_use]
143pub fn resolve_all_imports(input: &ResolveAllImportsInput<'_>) -> Vec<ResolvedModule> {
144    let session = ResolverSession::new(input);
145    resolve_all_imports_with_session(input, &session)
146}
147
148/// Resolve all imports for `input`, reusing a prebuilt [`ResolverSession`].
149///
150/// This is the single resolution code path; [`resolve_all_imports`] is the
151/// convenience wrapper that builds a fresh session first. `session` MUST have
152/// been built from an input with the same project context as `input` (same
153/// `root`, `workspaces` slice and order, `active_plugins`, and
154/// `extra_conditions`); only `modules` and `files` may differ.
155#[must_use]
156pub fn resolve_all_imports_with_session(
157    input: &ResolveAllImportsInput<'_>,
158    session: &ResolverSession,
159) -> Vec<ResolvedModule> {
160    let root_is_canonical = session.root_is_canonical;
161    let workspace_roots = build_workspace_roots(input.workspaces, &session.canonical_ws_roots);
162    let canonical_paths = build_canonical_file_paths(input.files, root_is_canonical);
163    let path_to_id = build_path_to_id(input.files, &canonical_paths, root_is_canonical);
164    let raw_path_to_id: FxHashMap<&Path, FileId> = input
165        .files
166        .iter()
167        .map(|f| (f.path.as_path(), f.id))
168        .collect();
169
170    let file_paths: Vec<&Path> = input.files.iter().map(|f| f.path.as_path()).collect();
171
172    let canonical_fallback = if root_is_canonical {
173        Some(types::CanonicalFallback::new(input.files))
174    } else {
175        None
176    };
177
178    let tsconfig_warned: Mutex<FxHashSet<String>> = Mutex::new(FxHashSet::default());
179
180    let ctx = ResolveContext {
181        resolver: &session.resolver,
182        style_resolver: &session.style_resolver,
183        extensions: &session.extensions,
184        path_to_id: &path_to_id,
185        raw_path_to_id: &raw_path_to_id,
186        workspace_roots: &workspace_roots,
187        package_manifests: &session.package_manifests,
188        condition_names: &session.condition_names,
189        path_aliases: input.path_aliases,
190        scss_include_paths: input.scss_include_paths,
191        static_dir_mappings: input.static_dir_mappings,
192        root: input.root,
193        canonical_fallback: canonical_fallback.as_ref(),
194        tsconfig_warned: &tsconfig_warned,
195    };
196
197    let mut resolved: Vec<ResolvedModule> = input
198        .modules
199        .par_iter()
200        .filter_map(|module| {
201            resolve_module_imports(module, &ctx, &file_paths, &canonical_paths, input.files)
202        })
203        .collect();
204
205    apply_specifier_upgrades(&mut resolved);
206
207    synthesize_auto_import_edges(
208        &mut resolved,
209        input.modules,
210        input.auto_imports,
211        &path_to_id,
212        &raw_path_to_id,
213    );
214
215    resolved
216}
217
218fn build_workspace_roots<'a>(
219    workspaces: &'a [fallow_config::WorkspaceInfo],
220    canonical_ws_roots: &'a [PathBuf],
221) -> FxHashMap<&'a str, &'a Path> {
222    workspaces
223        .iter()
224        .zip(canonical_ws_roots.iter())
225        .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
226        .collect()
227}
228
229fn build_canonical_file_paths(files: &[DiscoveredFile], root_is_canonical: bool) -> Vec<PathBuf> {
230    if root_is_canonical {
231        return Vec::new();
232    }
233
234    files
235        .par_iter()
236        .map(|f| dunce::canonicalize(&f.path).unwrap_or_else(|_| f.path.clone()))
237        .collect()
238}
239
240/// Load the root package manifest plus each workspace manifest into the
241/// `PackageManifestInfo` list used for `exports` / `imports` resolution.
242fn build_package_manifests(
243    input: &ResolveAllImportsInput<'_>,
244    canonical_ws_roots: &[PathBuf],
245) -> Vec<PackageManifestInfo> {
246    let root_canonical =
247        dunce::canonicalize(input.root).unwrap_or_else(|_| input.root.to_path_buf());
248    let mut package_manifests = Vec::new();
249    if let Ok(package_json) = fallow_config::PackageJson::load(&input.root.join("package.json")) {
250        package_manifests.push(PackageManifestInfo {
251            root: input.root.to_path_buf(),
252            canonical_root: root_canonical,
253            name: package_json.name.clone(),
254            package_json,
255        });
256    }
257    for (ws, canonical_root) in input.workspaces.iter().zip(canonical_ws_roots.iter()) {
258        if let Ok(package_json) = fallow_config::PackageJson::load(&ws.root.join("package.json")) {
259            package_manifests.push(PackageManifestInfo {
260                root: ws.root.clone(),
261                canonical_root: canonical_root.clone(),
262                name: package_json.name.clone().or_else(|| Some(ws.name.clone())),
263                package_json,
264            });
265        }
266    }
267    package_manifests
268}
269
270/// Build the path-to-`FileId` index, keyed by canonical paths when the root is
271/// not already canonical and by raw paths otherwise.
272fn build_path_to_id<'a>(
273    files: &'a [DiscoveredFile],
274    canonical_paths: &'a [PathBuf],
275    root_is_canonical: bool,
276) -> FxHashMap<&'a Path, FileId> {
277    if root_is_canonical {
278        files.iter().map(|f| (f.path.as_path(), f.id)).collect()
279    } else {
280        canonical_paths
281            .iter()
282            .enumerate()
283            .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
284            .collect()
285    }
286}
287
288fn resolve_module_imports(
289    module: &ModuleInfo,
290    ctx: &ResolveContext<'_>,
291    file_paths: &[&Path],
292    canonical_paths: &[PathBuf],
293    files: &[DiscoveredFile],
294) -> Option<ResolvedModule> {
295    let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
296        tracing::warn!(
297            file_id = module.file_id.0,
298            "Skipping module with unknown file_id during resolution"
299        );
300        return None;
301    };
302
303    let mut all_imports = resolve_static_imports(ctx, file_path, &module.imports);
304    all_imports.extend(resolve_require_imports(
305        ctx,
306        file_path,
307        &module.require_calls,
308    ));
309
310    let from_dir = if canonical_paths.is_empty() {
311        file_path.parent().unwrap_or(file_path)
312    } else {
313        canonical_paths
314            .get(module.file_id.0 as usize)
315            .and_then(|p| p.parent())
316            .unwrap_or(file_path)
317    };
318
319    Some(build_resolved_module(ResolvedModuleBuildInput {
320        module,
321        ctx,
322        file_path,
323        from_dir,
324        canonical_paths,
325        files,
326        all_imports,
327    }))
328}
329
330struct ResolvedModuleBuildInput<'a> {
331    module: &'a ModuleInfo,
332    ctx: &'a ResolveContext<'a>,
333    file_path: &'a Path,
334    from_dir: &'a Path,
335    canonical_paths: &'a [PathBuf],
336    files: &'a [DiscoveredFile],
337    all_imports: Vec<types::ResolvedImport>,
338}
339
340fn build_resolved_module(input: ResolvedModuleBuildInput<'_>) -> ResolvedModule {
341    ResolvedModule {
342        file_id: input.module.file_id,
343        path: input.file_path.to_path_buf(),
344        exports: input.module.exports.clone(),
345        re_exports: resolve_re_exports(input.ctx, input.file_path, &input.module.re_exports),
346        resolved_imports: input.all_imports,
347        resolved_dynamic_imports: resolve_dynamic_imports(
348            input.ctx,
349            input.file_path,
350            &input.module.dynamic_imports,
351        ),
352        resolved_dynamic_patterns: resolve_dynamic_patterns(
353            input.from_dir,
354            &input.module.dynamic_import_patterns,
355            input.canonical_paths,
356            input.files,
357        ),
358        member_accesses: input.module.member_accesses.clone(),
359        whole_object_uses: input.module.whole_object_uses.clone(),
360        has_cjs_exports: input.module.has_cjs_exports,
361        has_angular_component_template_url: input.module.has_angular_component_template_url,
362        unused_import_bindings: input
363            .module
364            .unused_import_bindings
365            .iter()
366            .cloned()
367            .collect(),
368        type_referenced_import_bindings: input.module.type_referenced_import_bindings.clone(),
369        value_referenced_import_bindings: input.module.value_referenced_import_bindings.clone(),
370        namespace_object_aliases: input.module.namespace_object_aliases.clone(),
371    }
372}
373
374/// Synthesize module-graph edges for convention auto-imports.
375///
376/// For each module, every captured `auto_import_candidates` name is matched
377/// against the active plugins' auto-import table; on a hit a synthetic
378/// [`ResolvedImport`] is added so the existing graph builder credits the edge.
379/// Name collisions across files over-credit every match, keeping each provider
380/// reachable. Resolution is recomputed from the live file index each run.
381fn synthesize_auto_import_edges(
382    resolved: &mut [ResolvedModule],
383    modules: &[ModuleInfo],
384    auto_imports: &[AutoImportRule],
385    path_to_id: &FxHashMap<&Path, FileId>,
386    raw_path_to_id: &FxHashMap<&Path, FileId>,
387) {
388    if auto_imports.is_empty() {
389        return;
390    }
391
392    let mut table: FxHashMap<&str, Vec<(FileId, AutoImportKind)>> = FxHashMap::default();
393    for rule in auto_imports {
394        let source = rule.source.as_path();
395        let Some(file_id) = raw_path_to_id
396            .get(source)
397            .or_else(|| path_to_id.get(source))
398            .copied()
399        else {
400            continue;
401        };
402        table
403            .entry(rule.name.as_str())
404            .or_default()
405            .push((file_id, rule.kind));
406    }
407    if table.is_empty() {
408        return;
409    }
410
411    let candidates: FxHashMap<FileId, &[String]> = modules
412        .iter()
413        .filter(|module| !module.auto_import_candidates.is_empty())
414        .map(|module| (module.file_id, module.auto_import_candidates.as_slice()))
415        .collect();
416    if candidates.is_empty() {
417        return;
418    }
419
420    for module in resolved.iter_mut() {
421        let Some(names) = candidates.get(&module.file_id) else {
422            continue;
423        };
424        for name in *names {
425            if is_auto_import_builtin(name) {
426                continue;
427            }
428            let Some(targets) = table.get(name.as_str()) else {
429                continue;
430            };
431            for (target_id, kind) in targets {
432                if *target_id == module.file_id {
433                    continue;
434                }
435                module.resolved_imports.push(ResolvedImport {
436                    info: synthetic_auto_import_info(name, *kind),
437                    target: ResolveResult::InternalModule(*target_id),
438                });
439            }
440        }
441    }
442}
443
444fn is_auto_import_builtin(name: &str) -> bool {
445    is_js_auto_import_builtin(name)
446        || is_vue_auto_import_builtin(name)
447        || is_nuxt_auto_import_builtin(name)
448}
449
450fn is_js_auto_import_builtin(name: &str) -> bool {
451    matches!(
452        name,
453        "AbortController"
454            | "AbortSignal"
455            | "Array"
456            | "ArrayBuffer"
457            | "BigInt"
458            | "Blob"
459            | "Boolean"
460            | "Buffer"
461            | "CSS"
462            | "DOMParser"
463            | "Date"
464            | "Document"
465            | "Error"
466            | "Event"
467            | "EventTarget"
468            | "File"
469            | "FormData"
470            | "Intl"
471            | "JSON"
472            | "Map"
473            | "Math"
474            | "Number"
475            | "Object"
476            | "Promise"
477            | "Reflect"
478            | "RegExp"
479            | "Response"
480            | "Set"
481            | "String"
482            | "Symbol"
483            | "URL"
484            | "URLSearchParams"
485            | "WeakMap"
486            | "WeakSet"
487            | "Window"
488            | "alert"
489            | "clearInterval"
490            | "clearTimeout"
491            | "console"
492            | "document"
493            | "fetch"
494            | "global"
495            | "globalThis"
496            | "localStorage"
497            | "navigator"
498            | "process"
499            | "requestAnimationFrame"
500            | "sessionStorage"
501            | "setInterval"
502            | "setTimeout"
503            | "window"
504    )
505}
506
507fn is_vue_auto_import_builtin(name: &str) -> bool {
508    matches!(name, |"computed"| "customRef"
509        | "defineAsyncComponent"
510        | "defineComponent"
511        | "effectScope"
512        | "getCurrentInstance"
513        | "h"
514        | "inject"
515        | "isProxy"
516        | "isReactive"
517        | "isReadonly"
518        | "isRef"
519        | "markRaw"
520        | "nextTick"
521        | "onActivated"
522        | "onBeforeMount"
523        | "onBeforeUnmount"
524        | "onBeforeUpdate"
525        | "onDeactivated"
526        | "onErrorCaptured"
527        | "onMounted"
528        | "onRenderTracked"
529        | "onRenderTriggered"
530        | "onScopeDispose"
531        | "onServerPrefetch"
532        | "onUnmounted"
533        | "onUpdated"
534        | "provide"
535        | "reactive"
536        | "readonly"
537        | "ref"
538        | "resolveComponent"
539        | "shallowReactive"
540        | "shallowReadonly"
541        | "shallowRef"
542        | "toRaw"
543        | "toRef"
544        | "toRefs"
545        | "triggerRef"
546        | "unref"
547        | "watch"
548        | "watchEffect"
549        | "watchPostEffect"
550        | "watchSyncEffect")
551}
552
553fn is_nuxt_auto_import_builtin(name: &str) -> bool {
554    matches!(name, |"useAsyncData"| "useCookie"
555        | "useError"
556        | "useFetch"
557        | "useHead"
558        | "useLazyAsyncData"
559        | "useLazyFetch"
560        | "useNuxtApp"
561        | "useRequestEvent"
562        | "useRequestHeaders"
563        | "useRoute"
564        | "useRouter"
565        | "useRuntimeConfig"
566        | "useSeoMeta"
567        | "useState")
568}
569
570/// Build a synthetic [`ImportInfo`] for a convention auto-import. Component and
571/// default kinds credit the default export; named kinds credit the named export.
572fn synthetic_auto_import_info(name: &str, kind: AutoImportKind) -> ImportInfo {
573    let imported_name = match kind {
574        AutoImportKind::Named => ImportedName::Named(name.to_string()),
575        AutoImportKind::Default | AutoImportKind::DefaultComponent => ImportedName::Default,
576    };
577    ImportInfo {
578        source: format!("<auto-import:{name}>"),
579        imported_name,
580        local_name: name.to_string(),
581        is_type_only: false,
582        from_style: false,
583        span: Span::default(),
584        source_span: Span::default(),
585    }
586}