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