feat(resolver): implement browser field (#561)

This commit is contained in:
Boshen 2023-07-17 15:16:08 +08:00 committed by GitHub
parent 8774956e66
commit cac4e73461
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 263 additions and 106 deletions

View file

@ -3,6 +3,7 @@
## TODO
- [ ] use `thiserror` for better error messages
- [ ] copy API documentation from webpack https://webpack.js.org/configuration/resolve/#resolve
#### Resolver Options
@ -41,7 +42,7 @@ Tests ported from [enhanced-resolve](https://github.com/webpack/enhanced-resolve
- [ ] CachedInputFileSystem.test.js
- [ ] SyncAsyncFileSystemDecorator.test.js
- [ ] alias.test.js
- [ ] browserField.test.js
- [x] browserField.test.js (reading the browser field is currently static - not read from the `browserField` option)
- [ ] dependencies.test.js
- [ ] exportsField.test.js
- [x] extension-alias.test.js

View file

@ -1,16 +1,36 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::request::RequestError;
#[derive(Debug, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ResolveError {
NotFound,
/// Path not found
NotFound(Box<Path>),
/// Ignored path
///
/// Derived from ignored path (false value) from browser field in package.json
/// ```json
/// {
/// "browser": {
/// "./module": false
/// }
/// }
/// ```
/// See <https://github.com/defunctzombie/package-browser-field-spec#ignore-a-module>
Ignored(Box<Path>),
/// The provided path request cannot be parsed
Request(RequestError),
/// All of the aliased extension are not found
ExtensionAlias,
/// JSON parse error
JSON(JSONError),
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct JSONError {
pub path: PathBuf,
pub message: String,

View file

@ -1,10 +1,9 @@
use std::{
fs,
path::{Path, PathBuf},
};
use std::{fs, path::Path, sync::Arc};
use dashmap::DashMap;
use crate::{package_json::PackageJson, ResolveError};
#[derive(Debug, Clone, Copy)]
pub struct FileMetadata {
is_file: bool,
@ -13,23 +12,35 @@ pub struct FileMetadata {
/// [File System](https://doc.rust-lang.org/stable/std/fs/) with caching
#[derive(Default)]
pub struct FileSystem {
cache: DashMap<PathBuf, Option<FileMetadata>>,
cache: DashMap<Box<Path>, Option<FileMetadata>>,
package_json_cache: DashMap<Box<Path>, Result<Arc<PackageJson>, ResolveError>>,
}
impl FileSystem {
/// <https://doc.rust-lang.org/stable/std/fs/fn.metadata.html>
pub fn metadata<P: AsRef<Path>>(&self, path: P) -> Option<FileMetadata> {
let path = path.as_ref();
pub fn metadata(&self, path: &Path) -> Option<FileMetadata> {
if let Some(result) = self.cache.get(path) {
return *result;
}
let file_metadata =
fs::metadata(path).ok().map(|metadata| FileMetadata { is_file: metadata.is_file() });
self.cache.insert(path.to_path_buf(), file_metadata);
self.cache.insert(path.to_path_buf().into_boxed_path(), file_metadata);
file_metadata
}
pub fn is_file<P: AsRef<Path>>(&self, path: P) -> bool {
pub fn is_file(&self, path: &Path) -> bool {
self.metadata(path).is_some_and(|m| m.is_file)
}
pub fn read_package_json(&self, path: &Path) -> Result<Arc<PackageJson>, ResolveError> {
if let Some(result) = self.package_json_cache.get(path) {
return result.value().clone();
}
let package_json_string = fs::read_to_string(path).unwrap();
let result = PackageJson::parse(path.to_path_buf(), &package_json_string)
.map(Arc::new)
.map_err(|error| ResolveError::from_serde_json_error(path.to_path_buf(), &error));
self.package_json_cache.insert(path.to_path_buf().into_boxed_path(), result.clone());
result
}
}

View file

@ -1,8 +1,10 @@
//! # Oxc Resolver
//!
//! Tests ported from [enhanced-resolve](https://github.com/webpack/enhanced-resolve).
//! ## References:
//!
//! Algorithm from <https://nodejs.org/api/modules.html#all-together>.
//! * Tests ported from [enhanced-resolve](https://github.com/webpack/enhanced-resolve)
//! * Algorithm adapted from [Node.js Module Resolution Algorithm](https://nodejs.org/api/modules.html#all-together) and [cjs loader](https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js)
//! * Some code adapted from [parcel-resolver](https://github.com/parcel-bundler/parcel/blob/v2/packages/utils/node-resolver-rs)
mod error;
mod file_system;
@ -13,7 +15,6 @@ mod request;
use std::{
ffi::OsStr,
fs,
path::{Path, PathBuf},
};
@ -23,7 +24,6 @@ pub use crate::{
};
use crate::{
file_system::FileSystem,
package_json::PackageJson,
path::PathUtil,
request::{Request, RequestPath},
};
@ -80,7 +80,7 @@ impl Resolver {
///
/// # Errors
///
/// * Will return `Err` for [ResolveError]
/// * See [ResolveError]
pub fn resolve<P: AsRef<Path>>(
&self,
path: P,
@ -114,6 +114,9 @@ impl Resolver {
}
// 3. If X begins with './' or '/' or '../'
RequestPath::Relative(relative_path) => {
if let Some(path) = self.load_package_self(path, relative_path)? {
return Ok(path);
}
let path = path.normalize_with(relative_path);
// a. LOAD_AS_FILE(Y + X)
if !relative_path.ends_with('/') {
@ -126,7 +129,7 @@ impl Resolver {
return Ok(path);
}
// c. THROW "not found"
return Err(ResolveError::NotFound);
return Err(ResolveError::NotFound(path.into_boxed_path()));
}
// 4. If X begins with '#'
// a. LOAD_PACKAGE_IMPORTS(X, dirname(Y))
@ -135,6 +138,9 @@ impl Resolver {
}
}
// 5. LOAD_PACKAGE_SELF(X, dirname(Y))
if let Some(path) = self.load_package_self(path, request_str)? {
return Ok(path);
}
// 6. LOAD_NODE_MODULES(X, dirname(Y))
if let Some(path) = self.load_node_modules(path, request_str)? {
return Ok(path);
@ -143,7 +149,7 @@ impl Resolver {
return Ok(path);
}
// 7. THROW "not found"
Err(ResolveError::NotFound)
Err(ResolveError::NotFound(path.to_path_buf().into_boxed_path()))
}
#[allow(clippy::unnecessary_wraps)]
@ -170,15 +176,12 @@ impl Resolver {
}
#[allow(clippy::unnecessary_wraps)]
fn load_index(&self, path: &Path, package_json: Option<&PackageJson>) -> ResolveState {
fn load_index(&self, path: &Path) -> ResolveState {
// 1. If X/index.js is a file, load X/index.js as JavaScript text. STOP
// 2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP
// 3. If X/index.node is a file, load X/index.node as binary addon. STOP
for extension in &self.options.extensions {
let mut index_path = path.join("index").with_extension(extension);
if let Some(resolved_path) = package_json.and_then(|p| p.resolve(&index_path)) {
index_path = resolved_path;
}
let index_path = path.join("index").with_extension(extension);
if self.fs.is_file(&index_path) {
return Ok(Some(index_path));
}
@ -191,9 +194,7 @@ impl Resolver {
let package_json_path = path.join("package.json");
if self.fs.is_file(&package_json_path) {
// a. Parse X/package.json, and look for "main" field.
let package_json_string = fs::read_to_string(&package_json_path).unwrap();
let package_json = PackageJson::parse(package_json_path.clone(), &package_json_string)
.map_err(|error| ResolveError::from_serde_json_error(package_json_path, &error))?;
let package_json = self.fs.read_package_json(&package_json_path)?;
// b. If "main" is a falsy value, GOTO 2.
if let Some(main_field) = &package_json.main {
// c. let M = X + (json main field)
@ -203,33 +204,31 @@ impl Resolver {
return Ok(Some(path));
}
// e. LOAD_INDEX(M)
if let Some(path) = self.load_index(&main_field_path, Some(&package_json))? {
if let Some(path) = self.load_index(&main_field_path)? {
return Ok(Some(path));
}
// f. LOAD_INDEX(X) DEPRECATED
// g. THROW "not found"
return Err(ResolveError::NotFound);
return Err(ResolveError::NotFound(main_field_path.into_boxed_path()));
}
// 2. LOAD_INDEX(X)
self.load_index(path, Some(&package_json))
} else {
// 2. LOAD_INDEX(X)
self.load_index(path, None)
}
// 2. LOAD_INDEX(X)
self.load_index(path)
}
fn load_node_modules(&self, start: &Path, request_str: &str) -> ResolveState {
const NODE_MODULES: &str = "node_modules";
// 1. let DIRS = NODE_MODULES_PATHS(START)
let dirs = start
.ancestors()
.filter(|path| path.file_name().is_some_and(|name| name != NODE_MODULES));
let dirs = Self::node_module_paths(start);
// 2. for each DIR in DIRS:
for dir in dirs {
let node_module_path = dir.join(NODE_MODULES);
for node_module_path in dirs {
let node_module_path = node_module_path.join(request_str);
for main_file in &self.options.main_files {
if let Some(path) = self.load_package_self(&node_module_path, main_file)? {
return Ok(Some(path));
}
}
// a. LOAD_PACKAGE_EXPORTS(X, DIR)
// b. LOAD_AS_FILE(DIR/X)
let node_module_path = node_module_path.join(request_str);
if !request_str.ends_with('/') {
if let Some(path) = self.load_as_file(&node_module_path)? {
return Ok(Some(path));
@ -243,6 +242,29 @@ impl Resolver {
Ok(None)
}
fn node_module_paths(path: &Path) -> impl Iterator<Item = PathBuf> + '_ {
path.ancestors()
.filter(|path| path.file_name().is_some_and(|name| name != "node_modules"))
.map(|path| path.join("node_modules"))
}
fn load_package_self(&self, path: &Path, request_str: &str) -> ResolveState {
for dir in path.ancestors() {
let package_json_path = dir.join("package.json");
if self.fs.is_file(&package_json_path) {
let package_json = self.fs.read_package_json(&package_json_path)?;
if let Some(request_str) =
package_json.resolve_request(path, request_str, &self.options.extensions)?
{
let request = Request::parse(request_str).map_err(ResolveError::Request)?;
// TODO: Do we need to pass query and fragment?
return self.require(dir, &request).map(Some);
}
}
}
Ok(None)
}
/// Given an extension alias map `{".js": [".ts", "js"]}`,
/// load the mapping instead of the provided extension
///

View file

@ -5,52 +5,88 @@ use std::{
use serde::Deserialize;
use crate::path::PathUtil;
use crate::{path::PathUtil, ResolveError};
// TODO: allocate everything into an arena or SoA
#[derive(Debug, Deserialize)]
pub struct PackageJson<'a> {
pub struct PackageJson {
#[serde(skip)]
pub path: PathBuf,
pub main: Option<&'a str>,
pub browser: Option<BrowserField<'a>>,
pub main: Option<String>,
pub browser: Option<BrowserField>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum BrowserField<'a> {
String(&'a str),
Map(HashMap<&'a str, &'a str>),
pub enum BrowserField {
String(String),
Map(HashMap<String, serde_json::Value>),
}
impl<'a> PackageJson<'a> {
pub fn parse(path: PathBuf, json: &'a str) -> Result<PackageJson<'a>, serde_json::Error> {
let mut package_json: PackageJson = serde_json::from_str(json)?;
impl PackageJson {
pub fn parse(path: PathBuf, json: &str) -> Result<Self, serde_json::Error> {
let mut package_json: Self = serde_json::from_str(json)?;
package_json.path = path;
Ok(package_json)
}
pub fn resolve(&self, path: &Path) -> Option<PathBuf> {
// TODO: return ResolveError if the provided `alias_fields` is not `browser` for future
// proof
let browser_field = self.browser.as_ref()?;
match browser_field {
BrowserField::Map(map) => {
/// Resolve the request string for this package.json by looking at the `browser` field.
///
/// # Errors
///
/// * Returns [ResolveError::Ignored] for `"path": false` in `browser` field.
pub fn resolve_request(
&self,
path: &Path,
request_str: &str,
extensions: &[String],
) -> Result<Option<&str>, ResolveError> {
// TODO: return ResolveError if the provided `alias_fields` is not `browser` for future proof
match self.browser.as_ref() {
Some(BrowserField::Map(map)) => {
for (key, value) in map {
let resolved_path = self.resolve_browser_field(key, value, path);
if resolved_path.is_some() {
return resolved_path;
if let Some(resolved_str) =
self.resolve_browser_field(path, key, value, request_str, extensions)?
{
return Ok(Some(resolved_str));
}
}
None
Ok(None)
}
// TODO: implement <https://github.com/defunctzombie/package-browser-field-spec#alternate-main---basic>
BrowserField::String(_) => None,
_ => Ok(None),
}
}
fn resolve_browser_field(&self, key: &str, value: &str, path: &Path) -> Option<PathBuf> {
// TODO: refactor this mess
fn resolve_browser_field<'a>(
&'a self,
start: &Path,
key: &str,
value: &'a serde_json::Value,
request_str: &str,
extensions: &[String],
) -> Result<Option<&str>, ResolveError> {
let directory = self.path.parent().unwrap(); // `unwrap`: this is a path to package.json, parent is its containing directory
// TODO: cache this join
(directory.join(key).normalize() == path).then(|| directory.join(value).normalize())
let right = directory.join(key).normalize();
let left = start.join(request_str).normalize();
if key == request_str
|| extensions
.iter()
.any(|ext| Path::new(request_str).with_extension(ext) == Path::new(key))
|| right == left
|| extensions.iter().any(|ext| left.with_extension(ext) == right)
{
if let serde_json::Value::String(value) = value {
return Ok(Some(value.as_str()));
}
// key match without string value, i.e. `"path": false` for ignore
let directory = self.path.parent().unwrap(); // `unwrap`: this is a path to package.json, parent is its containing directory
let path_key = directory.join(key).normalize();
return Err(ResolveError::Ignored(path_key.into_boxed_path()));
}
Ok(None)
}
}

View file

@ -1,16 +1,16 @@
#[derive(Debug, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum RequestError {
Empty,
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Request<'a> {
pub path: RequestPath<'a>,
pub query: Option<&'a str>,
pub fragment: Option<&'a str>,
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum RequestPath<'a> {
/// `/path`
Absolute(&'a str),

View file

@ -0,0 +1,70 @@
//! <https://github.com/webpack/enhanced-resolve/blob/main/test/browserField.test.js>
use std::path::PathBuf;
use oxc_resolver::{ResolveError, ResolveOptions, Resolver};
fn fixture() -> PathBuf {
super::fixture().join("browser-module")
}
#[test]
fn ignore() {
let f = fixture();
let options = ResolveOptions {
alias_fields: vec!["browser".into(), "innerBrowser1".into(), "innerBrowser2".into()],
..ResolveOptions::default()
};
let resolver = Resolver::new(options);
#[rustfmt::skip]
let data = [
(f.clone(), "./lib/ignore", f.join("lib/ignore.js")),
(f.clone(), "./lib/ignore.js", f.join("lib/ignore.js")),
(f.join("lib"), "./ignore", f.join("lib/ignore.js")),
(f.join("lib"), "./ignore.js", f.join("lib/ignore.js")),
];
for (path, request, expected) in data {
let resolution = resolver.resolve(&path, request);
let expected = ResolveError::Ignored(expected.into());
assert_eq!(resolution, Err(expected), "{path:?} {request}");
}
}
#[test]
fn replace_file() -> Result<(), ResolveError> {
let f = fixture();
let options =
ResolveOptions { alias_fields: vec!["browser".into()], ..ResolveOptions::default() };
let resolver = Resolver::new(options);
#[rustfmt::skip]
let data = [
("should replace a file 1", f.clone(), "./lib/replaced", f.join("lib/browser.js")),
("should replace a file 2", f.clone(), "./lib/replaced.js", f.join("lib/browser.js")),
("should replace a file 3", f.join("lib"), "./replaced", f.join("lib/browser.js")),
("should replace a file 4", f.join("lib"), "./replaced.js", f.join("lib/browser.js")),
("should replace a module with a file 1", f.clone(), "module-a", f.join("browser/module-a.js")),
("should replace a module with a file 2", f.join("lib"), "module-a", f.join("browser/module-a.js")),
("should replace a module with a module 1", f.clone(), "module-b", f.join("node_modules/module-c.js")),
("should replace a module with a module 2", f.join("lib"), "module-b", f.join("node_modules/module-c.js")),
// TODO: resolve `innerBrowser1` field in `browser-module/pakckage.json`
// ("should resolve in nested property 1", f.clone(), "./lib/main1.js", f.join("lib/main.js")),
// TODO: resolve `innerBrowser2` field in `browser-module/pakckage.json`
// ("should resolve in nested property 2", f.clone(), "./lib/main2.js", f.join("lib/browser.js")),
("should check only alias field properties", f.clone(), "./toString", f.join("lib/toString.js")),
];
for (comment, path, request, expected) in data {
let resolution = resolver.resolve(&path, request)?;
let resolved_path = resolution.path();
assert_eq!(resolved_path, expected, "{comment} {path:?} {request}");
}
Ok(())
}

View file

@ -10,7 +10,9 @@ fn fixture() -> PathBuf {
#[test]
fn extension_alias() -> Result<(), ResolveError> {
let options = ResolveOptions {
let f = fixture();
let resolver = Resolver::new(ResolveOptions {
extensions: vec![".js".into()],
main_files: vec!["index.js".into()],
extension_alias: vec![
@ -18,9 +20,7 @@ fn extension_alias() -> Result<(), ResolveError> {
(".mjs".into(), vec![".mts".into()]),
],
..ResolveOptions::default()
};
let resolver = Resolver::new(options);
let f = fixture();
});
#[rustfmt::skip]
let pass = [

View file

@ -10,43 +10,38 @@ fn fixture() -> PathBuf {
#[test]
fn extensions() -> Result<(), ResolveError> {
let fixture = fixture();
let f = fixture();
let options = ResolveOptions {
let resolver = Resolver::new(ResolveOptions {
extensions: vec![".ts".into(), ".js".into()],
..ResolveOptions::default()
};
let resolver = Resolver::new(options);
});
#[rustfmt::skip]
let pass = [
("should resolve according to order of provided extensions", "./foo", "foo.ts"),
(
"should resolve according to order of provided extensions (dir index)",
"./dir",
"dir/index.ts",
),
("should resolve according to order of provided extensions (dir index)", "./dir", "dir/index.ts"),
("should resolve according to main field in module root", ".", "index.js"),
("should resolve single file module before directory", "module", "node_modules/module.js"),
(
"should resolve trailing slash directory before single file",
"module/",
"node_modules/module/index.ts",
),
("should resolve trailing slash directory before single file", "module/", "node_modules/module/index.ts"),
];
for (comment, request, expected_path) in pass {
let resolution = resolver.resolve(&fixture, request)?;
let resolution = resolver.resolve(&f, request)?;
let path = resolution.path().canonicalize().unwrap();
let expected = fixture.join(expected_path).canonicalize().unwrap();
let expected = f.join(expected_path).canonicalize().unwrap();
assert_eq!(path, expected, "{comment} {request} {expected_path}");
}
let fail = [("not resolve to file when request has a trailing slash (relative)", "./foo.js/")];
#[rustfmt::skip]
let fail = [
("not resolve to file when request has a trailing slash (relative)", "./foo.js/", f.join("foo.js"))
];
for (comment, request) in fail {
let resolution = resolver.resolve(&fixture, request);
assert!(resolution.is_err(), "{comment} {request} {resolution:?}");
for (comment, request, expected_error) in fail {
let resolution = resolver.resolve(&f, request);
let error = ResolveError::NotFound(expected_error.into_boxed_path());
assert_eq!(resolution, Err(error), "{comment} {request} {resolution:?}");
}
Ok(())

View file

@ -1,3 +1,4 @@
mod browser_field;
mod extension_alias;
mod extensions;
mod incorrect_description_file;

View file

@ -5,6 +5,7 @@ use oxc_resolver::{ResolveError, Resolver};
#[test]
fn resolve() -> Result<(), ResolveError> {
let f = super::fixture();
let resolver = Resolver::default();
let main1_js_path = f.join("main1.js").to_string_lossy().to_string();

View file

@ -12,10 +12,10 @@ fn fixture() -> PathBuf {
fn scoped_packages() -> Result<(), ResolveError> {
let f = fixture();
let options =
ResolveOptions { alias_fields: vec!["browser".into()], ..ResolveOptions::default() };
let resolver = Resolver::new(options);
let resolver = Resolver::new(ResolveOptions {
alias_fields: vec!["browser".into()],
..ResolveOptions::default()
});
#[rustfmt::skip]
let pass = [

View file

@ -6,21 +6,21 @@ use oxc_resolver::{ResolveError, Resolver};
#[test]
fn simple() -> Result<(), ResolveError> {
// mimic `enhanced-resolve/test/simple.test.js`
let f = env::current_dir().unwrap().join("tests/enhanced_resolve/test/");
let resolver = Resolver::default();
// mimic `enhanced-resolve/test/simple.test.js`
let dirname = env::current_dir().unwrap().join("tests/enhanced_resolve/test/");
let data = [
("direct", dirname.clone(), "../lib/index"),
("as directory", dirname.clone(), ".."),
("as module", dirname.join("../../").canonicalize().unwrap(), "./enhanced_resolve"),
("direct", f.clone(), "../lib/index"),
("as directory", f.clone(), ".."),
("as module", f.join("../../").canonicalize().unwrap(), "./enhanced_resolve"),
];
for (comment, path, request) in data {
let resolution = resolver.resolve(&path, request)?;
let resolved_path = resolution.path().canonicalize().unwrap();
let expected = dirname.join("../lib/index.js").canonicalize().unwrap();
let expected = f.join("../lib/index.js").canonicalize().unwrap();
assert_eq!(resolved_path, expected, "{comment} {path:?} {request}");
}