diff --git a/Cargo.lock b/Cargo.lock index 5a56f7f25..ea10a9db0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -852,6 +852,7 @@ checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", "hashbrown 0.14.0", + "serde", ] [[package]] @@ -1588,6 +1589,7 @@ dependencies = [ "dashmap", "dunce", "identity-hash", + "indexmap 2.0.0", "jemallocator", "mimalloc", "nodejs-resolver", diff --git a/crates/oxc_resolver/Cargo.toml b/crates/oxc_resolver/Cargo.toml index 8580beceb..047471bbf 100644 --- a/crates/oxc_resolver/Cargo.toml +++ b/crates/oxc_resolver/Cargo.toml @@ -15,6 +15,7 @@ dashmap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } rustc-hash = { workspace = true } +indexmap = { workspace = true, features = ["serde"] } dunce = "1.0.4" identity-hash = "0.1.0" diff --git a/crates/oxc_resolver/src/lib.rs b/crates/oxc_resolver/src/lib.rs index 34cecaecb..c83c5eb2d 100644 --- a/crates/oxc_resolver/src/lib.rs +++ b/crates/oxc_resolver/src/lib.rs @@ -141,9 +141,6 @@ impl ResolverGeneric { cache_value: &CacheValue, request: &str, ) -> Result { - if let Some(path) = self.load_package_self(cache_value, request)? { - return Ok(path); - } let path = cache_value.path().normalize_with(request); let cache_value = self.cache.value(&path); // a. LOAD_AS_FILE(Y + X) @@ -321,32 +318,52 @@ impl ResolverGeneric { Ok(None) } - #[allow(clippy::unnecessary_wraps, clippy::unused_self)] - fn load_package_exports(&self, _path: &Path, _request: &str) -> ResolveState { + fn load_package_exports(&self, path: &Path, request: &str) -> ResolveState { + let cache_value = self.cache.value(&path.join(request)); // 1. Try to interpret X as a combination of NAME and SUBPATH where the name // may have a @scope/ prefix and the subpath begins with a slash (`/`). // 2. If X does not match this pattern or DIR/NAME/package.json is not a file, // return. + let Some(package_json) = cache_value.package_json(&self.cache.fs).transpose()? else { + return Ok(None); + }; // 3. Parse DIR/NAME/package.json, and look for "exports" field. // 4. If "exports" is null or undefined, return. + // (checked in package_json.package_exports_resolve) // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH, // `package.json` "exports", ["node", "require"]) defined in the ESM resolver. + if let Some(path) = package_json.package_exports_resolve(".") { + let cache_value = self.cache.value(&path); + return Ok(Some(cache_value)); + } // 6. RESOLVE_ESM_MATCH(MATCH) Ok(None) } - /// # Panics - /// - /// * Parent of package.json is None fn load_package_self(&self, cache_value: &CacheValue, request: &str) -> ResolveState { - if let Some(package_json) = cache_value.find_package_json(&self.cache.fs)? { - if let Some(path) = - self.load_browser_field(cache_value.path(), Some(request), &package_json)? - { - return Ok(Some(path)); + // 1. Find the closest package scope SCOPE to DIR. + // 2. If no scope was found, return. + let Some(package_json) = cache_value.find_package_json(&self.cache.fs)? else { + return Ok(None); + }; + // 3. If the SCOPE/package.json "exports" is null or undefined, return. + // (checked in package_json.package_exports_resolve) + // 4. If the SCOPE/package.json "name" is not the first segment of X, return. + // TODO: get first segment of X + if package_json.name.as_ref().is_some_and(|name| name.starts_with(request)) { + // return Ok(None); + // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE), + // "." + X.slice("name".length), `package.json` "exports", ["node", "require"]) + // defined in the ESM resolver. + if let Some(path) = package_json.package_exports_resolve(request) { + let cache_value = self.cache.value(&path); + return Ok(Some(cache_value)); } } - Ok(None) + // 6. RESOLVE_ESM_MATCH(MATCH) + + // Try non-spec-compliant "browser" field since its another form of export + self.load_browser_field(cache_value.path(), Some(request), &package_json) } fn load_browser_field( diff --git a/crates/oxc_resolver/src/package_json.rs b/crates/oxc_resolver/src/package_json.rs index 8a3f4ce69..5b31fee85 100644 --- a/crates/oxc_resolver/src/package_json.rs +++ b/crates/oxc_resolver/src/package_json.rs @@ -1,18 +1,42 @@ use std::{ - collections::HashMap, + hash::BuildHasherDefault, path::{Path, PathBuf}, }; +use indexmap::IndexMap; +use rustc_hash::FxHasher; use serde::Deserialize; use crate::{path::PathUtil, ResolveError}; +type FxIndexMap = IndexMap>; + // TODO: allocate everything into an arena or SoA #[derive(Debug, Deserialize)] pub struct PackageJson { #[serde(skip)] pub path: PathBuf, + + /// The "name" field defines your package's name. + /// The "name" field can be used in addition to the "exports" field to self-reference a package using its name. + /// + /// + pub name: Option, + + /// The "main" field defines the entry point of a package when imported by name via a node_modules lookup. Its value is a path. + /// When a package has an "exports" field, this will take precedence over the "main" field when importing the package by name. + /// + /// pub main: Option, + + /// The "exports" field allows defining the entry points of a package when imported by name loaded either via a node_modules lookup or a self-reference to its own name. + /// + /// + pub exports: Option, + + /// The browser field is provided by a module author as a hint to javascript bundlers or component tools when packaging modules for client side use. + /// + /// pub browser: Option, } @@ -20,7 +44,15 @@ pub struct PackageJson { #[serde(untagged)] pub enum BrowserField { String(String), - Map(HashMap), + Map(FxIndexMap), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum ExportsField { + String(String), + Array(Vec), + Map(FxIndexMap), } impl PackageJson { @@ -83,4 +115,22 @@ impl PackageJson { _ => Ok(None), } } + + /// [PACKAGE_EXPORTS_RESOLVE](https://nodejs.org/api/esm.html#resolution-algorithm-specification) + #[allow(clippy::single_match)] + pub fn package_exports_resolve(&self, request: &str) -> Option { + let Some(exports) = &self.exports else { + return None; + }; + match exports { + ExportsField::Map(map) => match map.get(request) { + Some(ExportsField::String(value)) => { + return Some(self.path.parent().unwrap().join(value)); + } + _ => {} + }, + _ => {} + } + None + } } diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs new file mode 100644 index 000000000..2c5d9a1d2 --- /dev/null +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs @@ -0,0 +1,20 @@ +//! https://github.com/webpack/enhanced-resolve/blob/main/test/exportsField.test.js +//! +//! The resolution tests are at the bottom of the file. + +use oxc_resolver::{Resolution, ResolveOptions, Resolver}; + +#[test] +fn exports_field() { + let fixture = super::fixture().join("exports-field"); + + let resolver = Resolver::new(ResolveOptions { + extensions: vec![".js".into()], + // fullySpecified: true, + // conditionNames: ["webpack"] + ..ResolveOptions::default() + }); + + let resolved_path = resolver.resolve(&fixture, "exports-field").map(Resolution::full_path); + assert_eq!(resolved_path, Ok(fixture.join("node_modules/exports-field/x.js"))); +} diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs index e183393ce..a95abe016 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs @@ -1,5 +1,6 @@ mod alias; mod browser_field; +mod exports_field; mod extension_alias; mod extensions; mod fallback;