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    let tsconfig_cache = types::TsconfigCache::default();
180    let canonicalize_cache = types::CanonicalizeCache::default();
181
182    let ctx = ResolveContext {
183        resolver: &session.resolver,
184        style_resolver: &session.style_resolver,
185        extensions: &session.extensions,
186        path_to_id: &path_to_id,
187        raw_path_to_id: &raw_path_to_id,
188        workspace_roots: &workspace_roots,
189        package_manifests: &session.package_manifests,
190        condition_names: &session.condition_names,
191        path_aliases: input.path_aliases,
192        scss_include_paths: input.scss_include_paths,
193        static_dir_mappings: input.static_dir_mappings,
194        root: input.root,
195        canonical_fallback: canonical_fallback.as_ref(),
196        tsconfig_warned: &tsconfig_warned,
197        tsconfig_cache: &tsconfig_cache,
198        canonicalize_cache: &canonicalize_cache,
199    };
200
201    let mut resolved: Vec<ResolvedModule> = input
202        .modules
203        .par_iter()
204        .filter_map(|module| {
205            resolve_module_imports(module, &ctx, &file_paths, &canonical_paths, input.files)
206        })
207        .collect();
208
209    apply_specifier_upgrades(&mut resolved);
210
211    synthesize_auto_import_edges(
212        &mut resolved,
213        input.modules,
214        input.auto_imports,
215        &path_to_id,
216        &raw_path_to_id,
217    );
218
219    resolved
220}
221
222fn build_workspace_roots<'a>(
223    workspaces: &'a [fallow_config::WorkspaceInfo],
224    canonical_ws_roots: &'a [PathBuf],
225) -> FxHashMap<&'a str, &'a Path> {
226    workspaces
227        .iter()
228        .zip(canonical_ws_roots.iter())
229        .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
230        .collect()
231}
232
233fn build_canonical_file_paths(files: &[DiscoveredFile], root_is_canonical: bool) -> Vec<PathBuf> {
234    if root_is_canonical {
235        return Vec::new();
236    }
237
238    files
239        .par_iter()
240        .map(|f| dunce::canonicalize(&f.path).unwrap_or_else(|_| f.path.clone()))
241        .collect()
242}
243
244/// Load the root package manifest plus each workspace manifest into the
245/// `PackageManifestInfo` list used for `exports` / `imports` resolution.
246fn build_package_manifests(
247    input: &ResolveAllImportsInput<'_>,
248    canonical_ws_roots: &[PathBuf],
249) -> Vec<PackageManifestInfo> {
250    let root_canonical =
251        dunce::canonicalize(input.root).unwrap_or_else(|_| input.root.to_path_buf());
252    let mut package_manifests = Vec::new();
253    if let Ok(package_json) = fallow_config::PackageJson::load(&input.root.join("package.json")) {
254        package_manifests.push(PackageManifestInfo {
255            root: input.root.to_path_buf(),
256            canonical_root: root_canonical,
257            name: package_json.name.clone(),
258            package_json,
259        });
260    }
261    for (ws, canonical_root) in input.workspaces.iter().zip(canonical_ws_roots.iter()) {
262        if let Ok(package_json) = fallow_config::PackageJson::load(&ws.root.join("package.json")) {
263            package_manifests.push(PackageManifestInfo {
264                root: ws.root.clone(),
265                canonical_root: canonical_root.clone(),
266                name: package_json.name.clone().or_else(|| Some(ws.name.clone())),
267                package_json,
268            });
269        }
270    }
271    package_manifests
272}
273
274/// Build the path-to-`FileId` index, keyed by canonical paths when the root is
275/// not already canonical and by raw paths otherwise.
276fn build_path_to_id<'a>(
277    files: &'a [DiscoveredFile],
278    canonical_paths: &'a [PathBuf],
279    root_is_canonical: bool,
280) -> FxHashMap<&'a Path, FileId> {
281    if root_is_canonical {
282        files.iter().map(|f| (f.path.as_path(), f.id)).collect()
283    } else {
284        canonical_paths
285            .iter()
286            .enumerate()
287            .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
288            .collect()
289    }
290}
291
292fn resolve_module_imports(
293    module: &ModuleInfo,
294    ctx: &ResolveContext<'_>,
295    file_paths: &[&Path],
296    canonical_paths: &[PathBuf],
297    files: &[DiscoveredFile],
298) -> Option<ResolvedModule> {
299    let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
300        tracing::warn!(
301            file_id = module.file_id.0,
302            "Skipping module with unknown file_id during resolution"
303        );
304        return None;
305    };
306
307    let mut all_imports = resolve_static_imports(ctx, file_path, &module.imports);
308    all_imports.extend(resolve_require_imports(
309        ctx,
310        file_path,
311        &module.require_calls,
312    ));
313
314    let from_dir = if canonical_paths.is_empty() {
315        file_path.parent().unwrap_or(file_path)
316    } else {
317        canonical_paths
318            .get(module.file_id.0 as usize)
319            .and_then(|p| p.parent())
320            .unwrap_or(file_path)
321    };
322
323    Some(build_resolved_module(ResolvedModuleBuildInput {
324        module,
325        ctx,
326        file_path,
327        from_dir,
328        canonical_paths,
329        files,
330        all_imports,
331    }))
332}
333
334struct ResolvedModuleBuildInput<'a> {
335    module: &'a ModuleInfo,
336    ctx: &'a ResolveContext<'a>,
337    file_path: &'a Path,
338    from_dir: &'a Path,
339    canonical_paths: &'a [PathBuf],
340    files: &'a [DiscoveredFile],
341    all_imports: Vec<types::ResolvedImport>,
342}
343
344fn build_resolved_module(input: ResolvedModuleBuildInput<'_>) -> ResolvedModule {
345    ResolvedModule {
346        file_id: input.module.file_id,
347        path: input.file_path.to_path_buf(),
348        exports: input.module.exports.clone(),
349        re_exports: resolve_re_exports(input.ctx, input.file_path, &input.module.re_exports),
350        resolved_imports: input.all_imports,
351        resolved_dynamic_imports: resolve_dynamic_imports(
352            input.ctx,
353            input.file_path,
354            &input.module.dynamic_imports,
355        ),
356        resolved_dynamic_patterns: resolve_dynamic_patterns(
357            input.from_dir,
358            &input.module.dynamic_import_patterns,
359            input.canonical_paths,
360            input.files,
361        ),
362        member_accesses: input.module.member_accesses.clone(),
363        semantic_facts: input.module.semantic_facts.clone(),
364        whole_object_uses: input.module.whole_object_uses.clone(),
365        has_cjs_exports: input.module.has_cjs_exports,
366        has_angular_component_template_url: input.module.has_angular_component_template_url,
367        unused_import_bindings: input
368            .module
369            .unused_import_bindings
370            .iter()
371            .cloned()
372            .collect(),
373        type_referenced_import_bindings: input.module.type_referenced_import_bindings.clone(),
374        value_referenced_import_bindings: input.module.value_referenced_import_bindings.clone(),
375        namespace_object_aliases: input.module.namespace_object_aliases.clone(),
376        exported_factory_returns: input.module.exported_factory_returns.clone(),
377    }
378}
379
380/// Synthesize module-graph edges for convention auto-imports.
381///
382/// For each module, every captured `auto_import_candidates` name is matched
383/// against the active plugins' auto-import table; on a hit a synthetic
384/// [`ResolvedImport`] is added so the existing graph builder credits the edge.
385/// Name collisions across files over-credit every match, keeping each provider
386/// reachable. Resolution is recomputed from the live file index each run.
387fn synthesize_auto_import_edges(
388    resolved: &mut [ResolvedModule],
389    modules: &[ModuleInfo],
390    auto_imports: &[AutoImportRule],
391    path_to_id: &FxHashMap<&Path, FileId>,
392    raw_path_to_id: &FxHashMap<&Path, FileId>,
393) {
394    if auto_imports.is_empty() {
395        return;
396    }
397
398    let mut table: FxHashMap<&str, Vec<(FileId, AutoImportKind)>> = FxHashMap::default();
399    for rule in auto_imports {
400        let source = rule.source.as_path();
401        let Some(file_id) = raw_path_to_id
402            .get(source)
403            .or_else(|| path_to_id.get(source))
404            .copied()
405        else {
406            continue;
407        };
408        table
409            .entry(rule.name.as_str())
410            .or_default()
411            .push((file_id, rule.kind));
412    }
413    if table.is_empty() {
414        return;
415    }
416
417    let candidates: FxHashMap<FileId, &[String]> = modules
418        .iter()
419        .filter(|module| !module.auto_import_candidates.is_empty())
420        .map(|module| (module.file_id, module.auto_import_candidates.as_slice()))
421        .collect();
422    if candidates.is_empty() {
423        return;
424    }
425
426    for module in resolved.iter_mut() {
427        let Some(names) = candidates.get(&module.file_id) else {
428            continue;
429        };
430        for name in *names {
431            if is_auto_import_builtin(name) {
432                continue;
433            }
434            let Some(targets) = table.get(name.as_str()) else {
435                continue;
436            };
437            for (target_id, kind) in targets {
438                if *target_id == module.file_id {
439                    continue;
440                }
441                module.resolved_imports.push(ResolvedImport {
442                    info: synthetic_auto_import_info(name, *kind),
443                    target: ResolveResult::SyntheticAutoImport(*target_id),
444                });
445            }
446        }
447    }
448}
449
450fn is_auto_import_builtin(name: &str) -> bool {
451    is_js_auto_import_builtin(name)
452        || is_vue_auto_import_builtin(name)
453        || is_nuxt_auto_import_builtin(name)
454}
455
456fn is_js_auto_import_builtin(name: &str) -> bool {
457    matches!(
458        name,
459        "AbortController"
460            | "AbortSignal"
461            | "Array"
462            | "ArrayBuffer"
463            | "BigInt"
464            | "Blob"
465            | "Boolean"
466            | "Buffer"
467            | "CSS"
468            | "DOMParser"
469            | "Date"
470            | "Document"
471            | "Error"
472            | "Event"
473            | "EventTarget"
474            | "File"
475            | "FormData"
476            | "Intl"
477            | "JSON"
478            | "Map"
479            | "Math"
480            | "Number"
481            | "Object"
482            | "Promise"
483            | "Reflect"
484            | "RegExp"
485            | "Response"
486            | "Set"
487            | "String"
488            | "Symbol"
489            | "URL"
490            | "URLSearchParams"
491            | "WeakMap"
492            | "WeakSet"
493            | "Window"
494            | "alert"
495            | "clearInterval"
496            | "clearTimeout"
497            | "console"
498            | "document"
499            | "fetch"
500            | "global"
501            | "globalThis"
502            | "localStorage"
503            | "navigator"
504            | "process"
505            | "requestAnimationFrame"
506            | "sessionStorage"
507            | "setInterval"
508            | "setTimeout"
509            | "window"
510    )
511}
512
513fn is_vue_auto_import_builtin(name: &str) -> bool {
514    matches!(name, |"computed"| "customRef"
515        | "defineAsyncComponent"
516        | "defineComponent"
517        | "effectScope"
518        | "getCurrentInstance"
519        | "h"
520        | "inject"
521        | "isProxy"
522        | "isReactive"
523        | "isReadonly"
524        | "isRef"
525        | "markRaw"
526        | "nextTick"
527        | "onActivated"
528        | "onBeforeMount"
529        | "onBeforeUnmount"
530        | "onBeforeUpdate"
531        | "onDeactivated"
532        | "onErrorCaptured"
533        | "onMounted"
534        | "onRenderTracked"
535        | "onRenderTriggered"
536        | "onScopeDispose"
537        | "onServerPrefetch"
538        | "onUnmounted"
539        | "onUpdated"
540        | "provide"
541        | "reactive"
542        | "readonly"
543        | "ref"
544        | "resolveComponent"
545        | "shallowReactive"
546        | "shallowReadonly"
547        | "shallowRef"
548        | "toRaw"
549        | "toRef"
550        | "toRefs"
551        | "triggerRef"
552        | "unref"
553        | "watch"
554        | "watchEffect"
555        | "watchPostEffect"
556        | "watchSyncEffect")
557}
558
559fn is_nuxt_auto_import_builtin(name: &str) -> bool {
560    matches!(name, |"useAsyncData"| "useCookie"
561        | "useError"
562        | "useFetch"
563        | "useHead"
564        | "useLazyAsyncData"
565        | "useLazyFetch"
566        | "useNuxtApp"
567        | "useRequestEvent"
568        | "useRequestHeaders"
569        | "useRoute"
570        | "useRouter"
571        | "useRuntimeConfig"
572        | "useSeoMeta"
573        | "useState")
574}
575
576/// Build a synthetic [`ImportInfo`] for a convention auto-import. Component and
577/// default kinds credit the default export; named kinds credit the named export.
578fn synthetic_auto_import_info(name: &str, kind: AutoImportKind) -> ImportInfo {
579    let imported_name = match kind {
580        AutoImportKind::Named => ImportedName::Named(name.to_string()),
581        AutoImportKind::Default | AutoImportKind::DefaultComponent => ImportedName::Default,
582    };
583    ImportInfo {
584        source: format!("<auto-import:{name}>"),
585        imported_name,
586        local_name: name.to_string(),
587        is_type_only: false,
588        from_style: false,
589        span: Span::default(),
590        source_span: Span::default(),
591    }
592}