feat(resolver): implement configurable exports_fields option (#733)

This commit is contained in:
Boshen 2023-08-14 10:18:57 +08:00 committed by GitHub
parent 087abd3cf1
commit f6e3b654b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 131 additions and 67 deletions

View file

@ -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"

View file

@ -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 |

View file

@ -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 == "." {

View file

@ -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);
}
}

View file

@ -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"));

View file

@ -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() {