Skip to main content

rolldown_resolver/
resolver.rs

1use std::{
2  path::{Path, PathBuf},
3  sync::Arc,
4};
5
6use anyhow::Context;
7use arcstr::ArcStr;
8use dashmap::DashMap;
9use oxc_resolver::{
10  ModuleType, PackageJson as OxcPackageJson, PackageType, Resolution, ResolveError,
11  ResolverGeneric, TsConfig as OxcTsConfig,
12};
13use rolldown_common::{
14  ImportKind, ModuleDefFormat, ModuleId, PackageJson, Platform, ResolveOptions, ResolvedId,
15  TsConfig,
16};
17use rolldown_fs::{FileSystem, OsFileSystem};
18use rolldown_utils::dashmap::FxDashMap;
19use sugar_path::SugarPath as _;
20
21use crate::resolver_config::ResolverConfig;
22
23#[derive(Debug)]
24#[expect(clippy::struct_field_names)]
25pub struct Resolver<Fs: FileSystem = OsFileSystem> {
26  fs: Fs,
27  cwd: PathBuf,
28  default_resolver: ResolverGeneric<Fs>,
29  // Resolver for `import '...'` and `import(...)`
30  import_resolver: ResolverGeneric<Fs>,
31  // Resolver for `require('...')`
32  require_resolver: ResolverGeneric<Fs>,
33  // Resolver for `@import '...'` and `url('...')`
34  css_resolver: ResolverGeneric<Fs>,
35  // Resolver for `new URL(..., import.meta.url)`
36  new_url_resolver: ResolverGeneric<Fs>,
37  package_json_cache: FxDashMap<PathBuf, Arc<PackageJson>>,
38}
39
40impl<Fs: FileSystem + Clone + 'static> Resolver<Fs> {
41  /// Creates a new resolver with the specified options.
42  pub fn new(
43    fs: Fs,
44    cwd: PathBuf,
45    platform: Platform,
46    tsconfig: &TsConfig,
47    resolve_options: ResolveOptions,
48  ) -> Self {
49    let config = ResolverConfig::build(&cwd, platform, tsconfig, resolve_options);
50
51    let default_resolver =
52      ResolverGeneric::new_with_file_system(fs.clone(), config.default_options);
53    let import_resolver = default_resolver.clone_with_options(config.import_options);
54    let require_resolver = default_resolver.clone_with_options(config.require_options);
55    let css_resolver = default_resolver.clone_with_options(config.css_options);
56    let new_url_resolver = default_resolver.clone_with_options(config.new_url_options);
57
58    Self {
59      fs,
60      cwd,
61      default_resolver,
62      import_resolver,
63      require_resolver,
64      css_resolver,
65      new_url_resolver,
66      package_json_cache: DashMap::default(),
67    }
68  }
69
70  /// The clone shares this resolver's cache, so tsconfig lookups and cache
71  /// clearing stay consistent with module resolution.
72  pub fn clone_default_resolver(&self) -> ResolverGeneric<Fs> {
73    self.default_resolver.clone_with_options(self.default_resolver.options().clone())
74  }
75}
76
77#[derive(Debug)]
78pub struct ResolveReturn {
79  pub path: ArcStr,
80  pub module_def_format: ModuleDefFormat,
81  pub package_json: Option<Arc<PackageJson>>,
82}
83
84impl From<ResolveReturn> for ResolvedId {
85  fn from(resolved_return: ResolveReturn) -> Self {
86    ResolvedId {
87      id: ModuleId::new(resolved_return.path),
88      module_def_format: resolved_return.module_def_format,
89      package_json: resolved_return.package_json,
90      ..Default::default()
91    }
92  }
93}
94
95impl<Fs: FileSystem> Resolver<Fs> {
96  pub fn cwd(&self) -> &PathBuf {
97    &self.cwd
98  }
99
100  pub fn clear_cache(&self) {
101    // All resolvers share the same cache, so just clear one of them is ok.
102    self.default_resolver.clear_cache();
103    self.package_json_cache.clear();
104  }
105
106  pub fn resolve_tsconfig<T: AsRef<Path>>(
107    &self,
108    path: &T,
109  ) -> Result<Arc<OxcTsConfig>, ResolveError> {
110    self.default_resolver.resolve_tsconfig(path)
111  }
112
113  pub fn try_get_package_json_or_create(&self, path: &Path) -> anyhow::Result<Arc<PackageJson>> {
114    self
115      .inner_try_get_package_json_or_create(path)
116      .with_context(|| format!("Failed to read or parse package.json: {}", path.display()))
117  }
118
119  fn inner_try_get_package_json_or_create(&self, path: &Path) -> anyhow::Result<Arc<PackageJson>> {
120    if let Some(v) = self.package_json_cache.get(path) {
121      Ok(Arc::clone(v.value()))
122    } else {
123      // User has the responsibility to ensure `path` is real path if needed. We just pass it through.
124      let realpath = path.to_path_buf();
125      let json_bytes = self.fs.read(path)?;
126      let oxc_pkg_json = OxcPackageJson::parse(&self.fs, realpath.clone(), realpath, json_bytes)?;
127      let pkg_json = Arc::new(PackageJson::from_oxc_pkg_json(&oxc_pkg_json));
128      self.package_json_cache.insert(path.to_path_buf(), Arc::clone(&pkg_json));
129      Ok(pkg_json)
130    }
131  }
132
133  /// Resolves a module specifier to an absolute path.
134  ///
135  /// # Arguments
136  /// * `importer` - The file that is importing the module (if any)
137  /// * `specifier` - The module specifier to resolve (e.g., "./foo", "lodash")
138  /// * `import_kind` - The type of import (ESM, CommonJS, CSS, etc.)
139  /// * `is_user_defined_entry` - Whether this is a user-defined entry point
140  ///
141  /// # Errors
142  /// Returns a `ResolveError` if the module cannot be resolved.
143  pub fn resolve(
144    &self,
145    importer: Option<&Path>,
146    specifier: &str,
147    import_kind: ImportKind,
148    is_user_defined_entry: bool,
149  ) -> Result<ResolveReturn, ResolveError> {
150    let selected_resolver = match import_kind {
151      ImportKind::Import | ImportKind::DynamicImport | ImportKind::HotAccept => {
152        &self.import_resolver
153      }
154      ImportKind::NewUrl => &self.new_url_resolver,
155      ImportKind::Require => &self.require_resolver,
156      ImportKind::AtImport | ImportKind::UrlImport => &self.css_resolver,
157    };
158
159    let mut resolution = if let Some(importer) = importer {
160      // check if `is_absolute` to avoid extra `join` overhead
161      if importer.is_absolute() {
162        selected_resolver.resolve_file(importer, specifier)
163      } else if importer.to_str().is_some_and(|id| id.starts_with('\0')) {
164        // A `\0` importer is a virtual module with no location: resolve from cwd without any tsconfig.
165        selected_resolver.resolve_with_context(
166          self.cwd.as_path(),
167          specifier,
168          None,
169          &mut oxc_resolver::ResolveContext::default(),
170        )
171      } else {
172        selected_resolver.resolve_file(self.cwd.join(importer), specifier)
173      }
174    } else {
175      selected_resolver.resolve(self.cwd.as_path(), specifier)
176    };
177
178    // Apply Rollup compatibility resolve when resolution fails and either:
179    // 1. It's a user-defined entry (e.g. `{ input: 'main' }` in rolldown config), or
180    // 2. There is no importer (e.g. `this.resolve('src/index.ts')` called from a plugin
181    //    without an importer), matching Rollup's behavior of resolving bare relative paths
182    //    against CWD when no importer is present.
183    if resolution.is_err() && (is_user_defined_entry || importer.is_none()) {
184      resolution =
185        self.try_rollup_compatibility_resolve(selected_resolver, importer, specifier, resolution);
186    }
187
188    resolution.map(|info| {
189      let package_json = info.package_json().map(|p| self.cached_package_json(p));
190      let module_def_format = infer_module_def_format(&info);
191      ResolveReturn {
192        path: info.full_path().to_str().expect("Should be valid utf8").into(),
193        module_def_format,
194        package_json,
195      }
196    })
197  }
198
199  fn cached_package_json(&self, oxc_pkg_json: &OxcPackageJson) -> Arc<PackageJson> {
200    Arc::clone(
201      self
202        .package_json_cache
203        .entry(oxc_pkg_json.realpath.clone())
204        .or_insert_with(|| Arc::new(PackageJson::from_oxc_pkg_json(oxc_pkg_json)))
205        .value(),
206    )
207  }
208
209  /// Attempts to resolve using Rollup compatibility mode.
210  ///
211  /// Rolldown doesn't pursue the exact same resolve behavior as Rollup, but in most cases
212  /// the results are the same. This function handles a special case for better compatibility.
213  ///
214  /// When a user writes config like `{ input: 'main' }`, `main` would be treated as a npm
215  /// package name in Rolldown and try to resolve it from `node_modules`. But Rollup will
216  /// resolve it to `<CWD>/main.{js,mjs,cjs}`.
217  ///
218  /// Related Rollup code: https://github.com/rollup/rollup/blob/680912e2ceb42c8d5e571e01c6ece0e4889aecbb/src/utils/resolveId.ts#L56
219  fn try_rollup_compatibility_resolve(
220    &self,
221    resolver: &ResolverGeneric<Fs>,
222    importer: Option<&Path>,
223    specifier: &str,
224    original_resolution: Result<Resolution, ResolveError>,
225  ) -> Result<Resolution, ResolveError> {
226    if specifier.starts_with('.') || specifier.starts_with('/') {
227      return original_resolution;
228    }
229
230    let importer_dir = importer
231      .and_then(|importer_path| importer_path.parent())
232      .and_then(|parent_dir| parent_dir.components().next().is_some().then_some(parent_dir))
233      .unwrap_or(self.cwd.as_path());
234
235    // Try resolving relative to cwd as a fallback
236    let joined = self.cwd.join(specifier);
237    let specifier_path = joined.normalize();
238    let fallback = resolver.resolve(importer_dir, &specifier_path.to_string_lossy());
239    if fallback.is_ok() { fallback } else { original_resolution }
240  }
241}
242
243/// Infer module format from file extension and package.json type field
244/// Reference: https://github.com/evanw/esbuild/blob/d34e79e2a998c21bb71d57b92b0017ca11756912/internal/bundler/bundler.go#L1446-L1460
245fn infer_module_def_format(info: &Resolution) -> ModuleDefFormat {
246  let fmt = ModuleDefFormat::from_path(info.path());
247  if !matches!(fmt, ModuleDefFormat::Unknown) {
248    return fmt;
249  }
250  let is_js_like_extension = info
251    .path()
252    .extension()
253    .is_some_and(|ext| matches!(ext.to_str(), Some("js" | "jsx" | "ts" | "tsx")));
254  if is_js_like_extension {
255    if let Some(module_type) = info.module_type() {
256      return match module_type {
257        ModuleType::CommonJs => ModuleDefFormat::CjsPackageJson,
258        ModuleType::Module => ModuleDefFormat::EsmPackageJson,
259        ModuleType::Json | ModuleType::Wasm | ModuleType::Addon => ModuleDefFormat::Unknown,
260      };
261    }
262    // `oxc_resolver` only resolves `module_type` from `package.json#type` for `.js`/`.ts`
263    // extensions, following the Node.js ESM spec which has no native `.jsx`/`.tsx`. As a bundler,
264    // rolldown also treats `.jsx`/`.tsx` according to the nearest `package.json#type`, so fall back
265    // to reading it directly here to keep all js-like extensions consistent.
266    // See https://github.com/rolldown/rolldown/issues/9690
267    if let Some(ty) = info.package_json().and_then(|pkg_json| pkg_json.r#type()) {
268      return match ty {
269        PackageType::CommonJs => ModuleDefFormat::CjsPackageJson,
270        PackageType::Module => ModuleDefFormat::EsmPackageJson,
271      };
272    }
273  }
274  ModuleDefFormat::Unknown
275}