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