diff --git a/crates/oxc_resolver/Cargo.toml b/crates/oxc_resolver/Cargo.toml index 925769bab..1bc284266 100644 --- a/crates/oxc_resolver/Cargo.toml +++ b/crates/oxc_resolver/Cargo.toml @@ -16,7 +16,7 @@ categories.workspace = true tracing = { workspace = true } dashmap = { workspace = true } serde = { workspace = true, features = ["derive"] } # derive for Deserialize from package.json -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } # preserve_order: package_json.exports requires order such as `["require", "import", "default"]` rustc-hash = { workspace = true } indexmap = { workspace = true, features = ["serde"] } # serde for Deserialize from package.json dunce = "1.0.4" diff --git a/crates/oxc_resolver/README.md b/crates/oxc_resolver/README.md index 4652e425f..bfcef59ca 100644 --- a/crates/oxc_resolver/README.md +++ b/crates/oxc_resolver/README.md @@ -16,7 +16,7 @@ | ✅ | conditionNames | [] | A list of exports field condition names | | ✅ | descriptionFiles | ["package.json"] | A list of description files to read from | | ✅ | enforceExtension | false | Enforce that a extension from extensions must be used | -| | exportsFields | ["exports"] | A list of exports fields in description files | +| ✅ | exportsFields | ["exports"] | A list of exports fields in description files | | ✅ | extensions | [".js", ".json", ".node"] | A list of extensions which should be tried for files | | ✅ | fallback | [] | Same as `alias`, but only used if default resolving fails | | ✅ | fileSystem | | The file system which should be used | diff --git a/crates/oxc_resolver/src/lib.rs b/crates/oxc_resolver/src/lib.rs index 4230a4ce1..64d101856 100644 --- a/crates/oxc_resolver/src/lib.rs +++ b/crates/oxc_resolver/src/lib.rs @@ -559,23 +559,25 @@ impl ResolverGeneric { }; // 3. Parse DIR/NAME/package.json, and look for "exports" field. // 4. If "exports" is null or undefined, return. - if package_json.exports.is_none() { + if package_json.exports.is_empty() { return Ok(None); }; // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH, // `package.json` "exports", ["node", "require"]) defined in the ESM resolver. // Note: The subpath is not prepended with a dot on purpose - let Some(path) = self.package_exports_resolve( - cached_path.path(), - subpath, - &package_json.exports, - &self.options.condition_names, - ctx - )? else { - return Ok(None) - }; - // 6. RESOLVE_ESM_MATCH(MATCH) - self.resolve_esm_match(&path, &package_json, ctx) + for exports in &package_json.exports { + if let Some(path) = self.package_exports_resolve( + cached_path.path(), + subpath, + exports, + &self.options.condition_names, + ctx, + )? { + // 6. RESOLVE_ESM_MATCH(MATCH) + return self.resolve_esm_match(&path, &package_json, ctx); + }; + } + Ok(None) } fn load_package_self( @@ -590,7 +592,7 @@ impl ResolverGeneric { return Ok(None); }; // 3. If the SCOPE/package.json "exports" is null or undefined, return. - if package_json.exports.is_none() { + if package_json.exports.is_empty() { return self.load_browser_field( cached_path.path(), Some(specifier), @@ -608,17 +610,19 @@ impl ResolverGeneric { let package_url = package_json.directory(); // Note: The subpath is not prepended with a dot on purpose // because `package_exports_resolve` matches subpath without the leading dot. - let Some(cached_path) = self.package_exports_resolve( - package_url, - subpath, - &package_json.exports, - &self.options.condition_names, - ctx - )? else { - return Ok(None); - }; - // 6. RESOLVE_ESM_MATCH(MATCH) - self.resolve_esm_match(&cached_path, &package_json, ctx) + for exports in &package_json.exports { + if let Some(cached_path) = self.package_exports_resolve( + package_url, + subpath, + exports, + &self.options.condition_names, + ctx, + )? { + // 6. RESOLVE_ESM_MATCH(MATCH) + return self.resolve_esm_match(&cached_path, &package_json, ctx); + } + } + Ok(None) } /// RESOLVE_ESM_MATCH(MATCH) @@ -760,15 +764,19 @@ impl ResolverGeneric { cached_path.package_json(&self.cache.fs, &self.options)? { // 5. If pjson is not null and pjson.exports is not null or undefined, then - if !package_json.exports.is_none() { + if !package_json.exports.is_empty() { // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions). - return self.package_exports_resolve( - cached_path.path(), - subpath, - &package_json.exports, - &self.options.condition_names, - ctx, - ); + for exports in &package_json.exports { + if let Some(path) = self.package_exports_resolve( + cached_path.path(), + subpath, + exports, + &self.options.condition_names, + ctx, + )? { + return Ok(Some(path)); + } + } } // 6. Otherwise, if packageSubpath is equal to ".", then if subpath == "." { diff --git a/crates/oxc_resolver/src/options.rs b/crates/oxc_resolver/src/options.rs index 2e318814d..9e6033a9f 100644 --- a/crates/oxc_resolver/src/options.rs +++ b/crates/oxc_resolver/src/options.rs @@ -41,10 +41,10 @@ pub struct ResolveOptions { pub enforce_extension: EnforceExtension, /// A list of exports fields in description files. - /// This is currently unused, but values are passed in for logging purposes. + /// Can be a path to json object such as `["path", "to", "exports"]`. /// - /// Default `[]`. Should be `["exports"]` when enabled. - pub exports_fields: Vec, + /// Default `[["exports"]]`. + pub exports_fields: Vec>, /// An object which maps extension to extension aliases. /// @@ -168,7 +168,7 @@ impl Default for ResolveOptions { description_files: vec!["package.json".into()], enforce_extension: EnforceExtension::Auto, extension_alias: vec![], - exports_fields: vec![], + exports_fields: vec![vec!["exports".into()]], extensions: vec![".js".into(), ".json".into(), ".node".into()], fallback: vec![], fully_specified: false, @@ -289,7 +289,7 @@ mod test { condition_names: vec!["require".into()], enforce_extension: EnforceExtension::Enabled, extension_alias: vec![(".js".into(), vec![".ts".into()])], - exports_fields: vec!["exports".into()], + exports_fields: vec![vec!["exports".into()]], fallback: vec![("fallback".into(), vec![AliasValue::Ignore])], fully_specified: true, resolve_to_context: true, @@ -300,7 +300,7 @@ mod test { ..ResolveOptions::default() }; - let expected = r#"alias:[("a", [Ignore])],alias_fields:["browser"],condition_names:["require"],enforce_extension:Enabled,exports_fields:["exports"],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,"#; + let expected = r#"alias:[("a", [Ignore])],alias_fields:["browser"],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,"#; assert_eq!(format!("{options}"), expected); } } diff --git a/crates/oxc_resolver/src/package_json.rs b/crates/oxc_resolver/src/package_json.rs index 0b6dc3ab0..38548ba44 100644 --- a/crates/oxc_resolver/src/package_json.rs +++ b/crates/oxc_resolver/src/package_json.rs @@ -8,13 +8,12 @@ use std::{ use indexmap::IndexMap; use rustc_hash::FxHasher; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use crate::{path::PathUtil, ResolveError, ResolveOptions}; type FxIndexMap = IndexMap>; -// TODO: allocate everything into an arena or SoA #[derive(Debug, Deserialize)] pub struct PackageJson { /// Path to `package.json`. Contains the `package.json` filename. @@ -39,8 +38,8 @@ pub struct PackageJson { /// 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. /// /// - #[serde(default)] - pub exports: ExportsField, + #[serde(skip)] + pub exports: Vec, /// In addition to the "exports" field, there is a package "imports" field to create private mappings that only apply to import specifiers from within the package itself. /// @@ -57,9 +56,9 @@ pub struct PackageJson { } /// `matchObj` defined in `PACKAGE_IMPORTS_EXPORTS_RESOLVE` +/// This is an IndexMap provided by the `preserve_order` feature. pub type MatchObject = FxIndexMap; -/// Coped from Parcel's resolver #[derive(Debug, Default, Deserialize)] #[serde(untagged)] pub enum ExportsField { @@ -70,13 +69,7 @@ pub enum ExportsField { Map(MatchObject), } -impl ExportsField { - pub fn is_none(&self) -> bool { - matches!(self, Self::None) - } -} - -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Debug, Eq, PartialEq, Hash)] pub enum ExportsKey { Main, Pattern(String), @@ -97,10 +90,10 @@ impl From<&str> for ExportsKey { } } -impl<'a, 'de: 'a> Deserialize<'de> for ExportsKey { +impl<'de> Deserialize<'de> for ExportsKey { fn deserialize(deserializer: D) -> Result where - D: serde::Deserializer<'de>, + D: Deserializer<'de>, { let s: &'de str = Deserialize::deserialize(deserializer)?; Ok(Self::from(s)) @@ -122,10 +115,11 @@ impl PackageJson { json: &str, options: &ResolveOptions, ) -> Result { - let mut package_json_value: serde_json::Value = serde_json::from_str(json.clone())?; - - let mut main_fields = Vec::with_capacity(options.main_fields.len()); - let mut browser_fields = Vec::with_capacity(options.alias_fields.len()); + let mut package_json_value: serde_json::Value = serde_json::from_str(json)?; + let mut package_json: Self = Self::deserialize(&package_json_value)?; + package_json.main_fields.reserve_exact(options.main_fields.len()); + package_json.browser_fields.reserve_exact(options.alias_fields.len()); + package_json.exports.reserve_exact(options.exports_fields.len()); if let Some(package_json_value) = package_json_value.as_object_mut() { // Dynamically create `main_fields`. @@ -135,7 +129,7 @@ impl PackageJson { if let Some(serde_json::Value::String(value)) = package_json_value.get(main_field_key) { - main_fields.push(value.clone()); + package_json.main_fields.push(value.clone()); } } // Dynamically create `browser_fields`. @@ -157,20 +151,42 @@ impl PackageJson { } } } - browser_fields.push(browser_field); + package_json.browser_fields.push(browser_field); } } } - // TODO: can this clone be avoided? - let mut package_json: Self = serde_json::from_str(json.clone())?; - package_json.main_fields = main_fields; - package_json.browser_fields = browser_fields; + // Dynamically create `exports`. + for object_path in &options.exports_fields { + let exports = Self::get_value_by_path(&package_json_value, object_path); + if let Some(exports) = exports { + let exports = ExportsField::deserialize(exports)?; + package_json.exports.push(exports); + } + } package_json.path = path; Ok(package_json) } + fn get_value_by_path<'a>( + package_json: &'a serde_json::Value, + path: &[String], + ) -> Option<&'a serde_json::Value> { + if path.is_empty() { + return None; + } + let mut value = package_json; + for key in path { + if let Some(inner_value) = value.as_object().and_then(|o| o.get(key)) { + value = inner_value; + } else { + return None; + } + } + Some(value) + } + /// Directory to `package.json` pub fn directory(&self) -> &Path { debug_assert!(self.path.file_name().is_some_and(|x| x == "package.json")); diff --git a/crates/oxc_resolver/src/tests/exports_field.rs b/crates/oxc_resolver/src/tests/exports_field.rs index b5b708779..fe225d876 100644 --- a/crates/oxc_resolver/src/tests/exports_field.rs +++ b/crates/oxc_resolver/src/tests/exports_field.rs @@ -124,9 +124,49 @@ fn extension_without_fully_specified() { assert_eq!(resolved_path, Ok(f2.join("node_modules/exports-field/lib/lib2/main.js"))); } -// #[test] -// field name path #1 - #5 -// fn field_name() {} +#[test] +fn field_name_path() { + let f2 = super::fixture().join("exports-field2"); + let f3 = super::fixture().join("exports-field3"); + + // field name path #1 #2 #3 + let exports_fields = [ + vec![vec!["exportsField".into(), "exports".into()]], + vec![vec!["exportsField".into(), "exports".into()], vec!["exports".into()]], + vec![vec!["exports".into()], vec!["exportsField".into(), "exports".into()]], + ]; + + for exports_fields in exports_fields { + let resolver = Resolver::new(ResolveOptions { + alias_fields: vec!["browser".into()], + exports_fields, + extensions: vec![".js".into()], + ..ResolveOptions::default() + }); + let resolved_path = resolver.resolve(&f3, "exports-field").map(|r| r.full_path()); + assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/main.js"))); + } + + // field name path #4 + let resolver = Resolver::new(ResolveOptions { + alias_fields: vec!["browser".into()], + exports_fields: vec![vec!["exports".into()]], + extensions: vec![".js".into()], + ..ResolveOptions::default() + }); + let resolved_path = resolver.resolve(&f2, "exports-field").map(|r| r.full_path()); + assert_eq!(resolved_path, Ok(f2.join("node_modules/exports-field/index.js"))); + + // field name path #5 + let resolver = Resolver::new(ResolveOptions { + alias_fields: vec!["browser".into()], + exports_fields: vec![vec!["ex".into()], vec!["exports_field".into(), "exports".into()]], + extensions: vec![".js".into()], + ..ResolveOptions::default() + }); + let resolved_path = resolver.resolve(&f3, "exports-field").map(|r| r.full_path()); + assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/index"))); +} #[test] fn extension_alias_1_2() {