mirror of
https://github.com/danbulant/oxc
synced 2026-05-24 20:32:10 +00:00
feat(resolver): implement configurable exports_fields option (#733)
This commit is contained in:
parent
087abd3cf1
commit
f6e3b654b1
6 changed files with 131 additions and 67 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -559,23 +559,25 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
|
|||
};
|
||||
// 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<Fs: FileSystem> ResolverGeneric<Fs> {
|
|||
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<Fs: FileSystem> ResolverGeneric<Fs> {
|
|||
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<Fs: FileSystem> ResolverGeneric<Fs> {
|
|||
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 == "." {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// Default `[["exports"]]`.
|
||||
pub exports_fields: Vec<Vec<String>>,
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<K, V> = IndexMap<K, V, BuildHasherDefault<FxHasher>>;
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// <https://nodejs.org/api/packages.html#exports>
|
||||
#[serde(default)]
|
||||
pub exports: ExportsField,
|
||||
#[serde(skip)]
|
||||
pub exports: Vec<ExportsField>,
|
||||
|
||||
/// 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<ExportsKey, ExportsField>;
|
||||
|
||||
/// 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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<Self, serde_json::Error> {
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue