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        type_member_types: input.module.type_member_types.clone(),
378    }
379}
380
381/// Synthesize module-graph edges for convention auto-imports.
382///
383/// For each module, every captured `auto_import_candidates` name is matched
384/// against the active plugins' auto-import table; on a hit a synthetic
385/// [`ResolvedImport`] is added so the existing graph builder credits the edge.
386/// Name collisions across files over-credit every match, keeping each provider
387/// reachable. Resolution is recomputed from the live file index each run.
388fn synthesize_auto_import_edges(
389    resolved: &mut [ResolvedModule],
390    modules: &[ModuleInfo],
391    auto_imports: &[AutoImportRule],
392    path_to_id: &FxHashMap<&Path, FileId>,
393    raw_path_to_id: &FxHashMap<&Path, FileId>,
394) {
395    if auto_imports.is_empty() {
396        return;
397    }
398
399    let mut table: FxHashMap<&str, Vec<(FileId, AutoImportKind)>> = FxHashMap::default();
400    for rule in auto_imports {
401        let source = rule.source.as_path();
402        let Some(file_id) = raw_path_to_id
403            .get(source)
404            .or_else(|| path_to_id.get(source))
405            .copied()
406        else {
407            continue;
408        };
409        table
410            .entry(rule.name.as_str())
411            .or_default()
412            .push((file_id, rule.kind));
413    }
414    if table.is_empty() {
415        return;
416    }
417
418    let candidates: FxHashMap<FileId, &[String]> = modules
419        .iter()
420        .filter(|module| !module.auto_import_candidates.is_empty())
421        .map(|module| (module.file_id, module.auto_import_candidates.as_slice()))
422        .collect();
423    if candidates.is_empty() {
424        return;
425    }
426
427    for module in resolved.iter_mut() {
428        let Some(names) = candidates.get(&module.file_id) else {
429            continue;
430        };
431        for name in *names {
432            if is_auto_import_builtin(name) {
433                continue;
434            }
435            let Some(targets) = table.get(name.as_str()) else {
436                continue;
437            };
438            for (target_id, kind) in targets {
439                if *target_id == module.file_id {
440                    continue;
441                }
442                module.resolved_imports.push(ResolvedImport {
443                    info: synthetic_auto_import_info(name, *kind),
444                    target: ResolveResult::SyntheticAutoImport(*target_id),
445                });
446            }
447        }
448    }
449}
450
451fn is_auto_import_builtin(name: &str) -> bool {
452    is_js_auto_import_builtin(name)
453        || is_vue_auto_import_builtin(name)
454        || is_nuxt_auto_import_builtin(name)
455}
456
457fn is_js_auto_import_builtin(name: &str) -> bool {
458    matches!(
459        name,
460        "AbortController"
461            | "AbortSignal"
462            | "Array"
463            | "ArrayBuffer"
464            | "BigInt"
465            | "Blob"
466            | "Boolean"
467            | "Buffer"
468            | "CSS"
469            | "DOMParser"
470            | "Date"
471            | "Document"
472            | "Error"
473            | "Event"
474            | "EventTarget"
475            | "File"
476            | "FormData"
477            | "Intl"
478            | "JSON"
479            | "Map"
480            | "Math"
481            | "Number"
482            | "Object"
483            | "Promise"
484            | "Reflect"
485            | "RegExp"
486            | "Response"
487            | "Set"
488            | "String"
489            | "Symbol"
490            | "URL"
491            | "URLSearchParams"
492            | "WeakMap"
493            | "WeakSet"
494            | "Window"
495            | "alert"
496            | "clearInterval"
497            | "clearTimeout"
498            | "console"
499            | "document"
500            | "fetch"
501            | "global"
502            | "globalThis"
503            | "localStorage"
504            | "navigator"
505            | "process"
506            | "requestAnimationFrame"
507            | "sessionStorage"
508            | "setInterval"
509            | "setTimeout"
510            | "window"
511    )
512}
513
514fn is_vue_auto_import_builtin(name: &str) -> bool {
515    matches!(name, |"computed"| "customRef"
516        | "defineAsyncComponent"
517        | "defineComponent"
518        | "effectScope"
519        | "getCurrentInstance"
520        | "h"
521        | "inject"
522        | "isProxy"
523        | "isReactive"
524        | "isReadonly"
525        | "isRef"
526        | "markRaw"
527        | "nextTick"
528        | "onActivated"
529        | "onBeforeMount"
530        | "onBeforeUnmount"
531        | "onBeforeUpdate"
532        | "onDeactivated"
533        | "onErrorCaptured"
534        | "onMounted"
535        | "onRenderTracked"
536        | "onRenderTriggered"
537        | "onScopeDispose"
538        | "onServerPrefetch"
539        | "onUnmounted"
540        | "onUpdated"
541        | "provide"
542        | "reactive"
543        | "readonly"
544        | "ref"
545        | "resolveComponent"
546        | "shallowReactive"
547        | "shallowReadonly"
548        | "shallowRef"
549        | "toRaw"
550        | "toRef"
551        | "toRefs"
552        | "triggerRef"
553        | "unref"
554        | "watch"
555        | "watchEffect"
556        | "watchPostEffect"
557        | "watchSyncEffect")
558}
559
560fn is_nuxt_auto_import_builtin(name: &str) -> bool {
561    matches!(name, |"useAsyncData"| "useCookie"
562        | "useError"
563        | "useFetch"
564        | "useHead"
565        | "useLazyAsyncData"
566        | "useLazyFetch"
567        | "useNuxtApp"
568        | "useRequestEvent"
569        | "useRequestHeaders"
570        | "useRoute"
571        | "useRouter"
572        | "useRuntimeConfig"
573        | "useSeoMeta"
574        | "useState")
575}
576
577/// Build a synthetic [`ImportInfo`] for a convention auto-import. Component and
578/// default kinds credit the default export; named kinds credit the named export.
579fn synthetic_auto_import_info(name: &str, kind: AutoImportKind) -> ImportInfo {
580    let imported_name = match kind {
581        AutoImportKind::Named => ImportedName::Named(name.to_string()),
582        AutoImportKind::Default | AutoImportKind::DefaultComponent => ImportedName::Default,
583    };
584    ImportInfo {
585        source: format!("<auto-import:{name}>"),
586        imported_name,
587        local_name: name.to_string(),
588        is_type_only: false,
589        from_style: false,
590        span: Span::default(),
591        source_span: Span::default(),
592    }
593}