feat(resolver): initialize implementation of package.json exports field (#630)

This commit is contained in:
Boshen 2023-07-26 16:54:54 +08:00 committed by GitHub
parent 1d504ac94a
commit 32b8ad2b57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 107 additions and 16 deletions

2
Cargo.lock generated
View file

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

View file

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

View file

@ -141,9 +141,6 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
cache_value: &CacheValue,
request: &str,
) -> Result<CacheValue, ResolveError> {
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<Fs: FileSystem> ResolverGeneric<Fs> {
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(

View file

@ -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<K, V> = IndexMap<K, V, BuildHasherDefault<FxHasher>>;
// 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.
///
/// <https://nodejs.org/api/packages.html#name>
pub name: Option<String>,
/// 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.
///
/// <https://nodejs.org/api/packages.html#main>
pub main: Option<String>,
/// 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>
pub exports: Option<ExportsField>,
/// 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.
///
/// <https://github.com/defunctzombie/package-browser-field-spec>
pub browser: Option<BrowserField>,
}
@ -20,7 +44,15 @@ pub struct PackageJson {
#[serde(untagged)]
pub enum BrowserField {
String(String),
Map(HashMap<PathBuf, serde_json::Value>),
Map(FxIndexMap<PathBuf, serde_json::Value>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ExportsField {
String(String),
Array(Vec<ExportsField>),
Map(FxIndexMap<String, ExportsField>),
}
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<PathBuf> {
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
}
}

View file

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

View file

@ -1,5 +1,6 @@
mod alias;
mod browser_field;
mod exports_field;
mod extension_alias;
mod extensions;
mod fallback;