From b2152ec0500b2727344652a7e814ab8c581e5e61 Mon Sep 17 00:00:00 2001 From: Boshen Date: Sun, 16 Jul 2023 20:36:47 +0800 Subject: [PATCH] feat(resolver): implement scoped packages (#558) --- crates/oxc_resolver/README.md | 10 ++-- crates/oxc_resolver/src/lib.rs | 31 +++++++----- crates/oxc_resolver/src/options.rs | 7 +++ crates/oxc_resolver/src/package_json.rs | 50 +++++++++++++++++-- crates/oxc_resolver/src/request.rs | 2 +- .../tests/enhanced_resolve/test/mod.rs | 1 + .../enhanced_resolve/test/scoped_packages.rs | 34 +++++++++++++ 7 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 crates/oxc_resolver/tests/enhanced_resolve/test/scoped_packages.rs diff --git a/crates/oxc_resolver/README.md b/crates/oxc_resolver/README.md index e4e4233fe..926634c66 100644 --- a/crates/oxc_resolver/README.md +++ b/crates/oxc_resolver/README.md @@ -1,11 +1,15 @@ # Oxc Resolver +## TODO + +- [ ] use `thiserror` for better error messages + #### Resolver Options | Done | Field | Default | Description | |------|------------------|-----------------------------| --------------------------------------------------------------------------------------------------------------------------------------------------------- | | | alias | [] | A list of module alias configurations or an object which maps key to value | -| | aliasFields | [] | A list of alias fields in description files | +| ✅ | aliasFields | [] | A list of alias fields in description files | | ✅ | extensionAlias | {} | An object which maps extension to extension aliases | | | cachePredicate | function() { return true }; | A function which decides whether a request should be cached or not. An object is passed to the function with `path` and `request` properties. | | | cacheWithContext | true | If unsafe cache is enabled, includes `request.context` in the cache key | @@ -18,7 +22,7 @@ | | fileSystem | | The file system which should be used | | | fullySpecified | false | Request passed to resolve is already fully specified and extensions or main files are not resolved for it (they are still resolved for internal requests) | | | mainFields | ["main"] | A list of main fields in description files | -| | mainFiles | ["index"] | A list of main files in directories | +| ✅ | mainFiles | ["index"] | A list of main files in directories | | | modules | ["node_modules"] | A list of directories to resolve modules from, can be absolute path or folder name | | | plugins | [] | A list of additional resolve plugins which should be applied | | | resolver | undefined | A prepared Resolver to which the plugins are attached | @@ -57,7 +61,7 @@ Tests ported from [enhanced-resolve](https://github.com/webpack/enhanced-resolve - [x] resolve.test.js (partially done) - [ ] restrictions.test.js - [ ] roots.test.js -- [ ] scoped-packages.test.js +- [x] scoped-packages.test.js - [x] simple.test.js - [ ] symlink.test.js - [ ] unsafe-cache.test.js diff --git a/crates/oxc_resolver/src/lib.rs b/crates/oxc_resolver/src/lib.rs index 23c783d6d..d33aaee48 100644 --- a/crates/oxc_resolver/src/lib.rs +++ b/crates/oxc_resolver/src/lib.rs @@ -169,13 +169,16 @@ impl Resolver { Ok(None) } - #[allow(clippy::unused_self, clippy::unnecessary_wraps)] - fn load_index(&self, path: &Path) -> ResolveState { + #[allow(clippy::unnecessary_wraps)] + fn load_index(&self, path: &Path, package_json: Option<&PackageJson>) -> 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 index_path = path.join("index").with_extension(extension); + 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; + } if self.fs.is_file(&index_path) { return Ok(Some(index_path)); } @@ -189,7 +192,7 @@ impl Resolver { 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::try_from(package_json_string.as_str()) + let package_json = PackageJson::parse(package_json_path.clone(), &package_json_string) .map_err(|error| ResolveError::from_serde_json_error(package_json_path, &error))?; // b. If "main" is a falsy value, GOTO 2. if let Some(main_field) = &package_json.main { @@ -200,19 +203,22 @@ impl Resolver { return Ok(Some(path)); } // e. LOAD_INDEX(M) - if let Some(path) = self.load_index(&main_field_path)? { + if let Some(path) = self.load_index(&main_field_path, Some(&package_json))? { return Ok(Some(path)); } // f. LOAD_INDEX(X) DEPRECATED + // g. THROW "not found" + return Err(ResolveError::NotFound); } - // g. THROW "not found" - return Err(ResolveError::NotFound); + // 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, module_path: &str) -> ResolveState { + 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 @@ -220,10 +226,11 @@ impl Resolver { .filter(|path| path.file_name().is_some_and(|name| name != NODE_MODULES)); // 2. for each DIR in DIRS: for dir in dirs { + let node_module_path = dir.join(NODE_MODULES); // a. LOAD_PACKAGE_EXPORTS(X, DIR) // b. LOAD_AS_FILE(DIR/X) - let node_module_path = dir.join(NODE_MODULES).join(module_path); - if !module_path.ends_with('/') { + 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)); } diff --git a/crates/oxc_resolver/src/options.rs b/crates/oxc_resolver/src/options.rs index 04ecd8c69..8c78bafc6 100644 --- a/crates/oxc_resolver/src/options.rs +++ b/crates/oxc_resolver/src/options.rs @@ -1,5 +1,11 @@ #[derive(Debug, Clone)] pub struct ResolveOptions { + /// A list of alias fields in description files. + /// Specify a field, such as `browser`, to be parsed according to [this specification](https://github.com/defunctzombie/package-browser-field-spec). + /// + /// Default `[]` + pub alias_fields: Vec, + /// An object which maps extension to extension aliases /// /// Default `{}` @@ -24,6 +30,7 @@ pub struct ResolveOptions { impl Default for ResolveOptions { fn default() -> Self { Self { + alias_fields: vec![], extension_alias: vec![], enforce_extension: false, extensions: vec![".js".into(), ".json".into(), ".node".into()], diff --git a/crates/oxc_resolver/src/package_json.rs b/crates/oxc_resolver/src/package_json.rs index 82816c61b..831bbe75a 100644 --- a/crates/oxc_resolver/src/package_json.rs +++ b/crates/oxc_resolver/src/package_json.rs @@ -1,14 +1,56 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + use serde::Deserialize; +use crate::path::PathUtil; + #[derive(Debug, Deserialize)] pub struct PackageJson<'a> { + #[serde(skip)] + pub path: PathBuf, pub main: Option<&'a str>, + pub browser: Option>, } -impl<'a> TryFrom<&'a str> for PackageJson<'a> { - type Error = serde_json::Error; +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum BrowserField<'a> { + String(&'a str), + Map(HashMap<&'a str, &'a str>), +} - fn try_from(s: &'a str) -> Result { - serde_json::from_str(s) +impl<'a> PackageJson<'a> { + pub fn parse(path: PathBuf, json: &'a str) -> Result, serde_json::Error> { + let mut package_json: PackageJson = serde_json::from_str(json)?; + package_json.path = path; + Ok(package_json) + } + + pub fn resolve(&self, path: &Path) -> Option { + // 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) => { + for (key, value) in map { + let resolved_path = self.resolve_browser_field(key, value, path); + if resolved_path.is_some() { + return resolved_path; + } + } + None + } + // TODO: implement + BrowserField::String(_) => None, + } + } + + fn resolve_browser_field(&self, key: &str, value: &str, path: &Path) -> Option { + 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()) } } diff --git a/crates/oxc_resolver/src/request.rs b/crates/oxc_resolver/src/request.rs index 6b08c771f..e66d50326 100644 --- a/crates/oxc_resolver/src/request.rs +++ b/crates/oxc_resolver/src/request.rs @@ -16,7 +16,7 @@ pub enum RequestPath<'a> { Absolute(&'a str), /// `./path`, `../path` Relative(&'a str), - /// `path` + /// `path`, `@scope/path` Module(&'a str), } diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs index b6dbefd10..01225e418 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs @@ -2,6 +2,7 @@ mod extension_alias; mod extensions; mod incorrect_description_file; mod resolve; +mod scoped_packages; mod simple; use std::{env, path::PathBuf}; diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/scoped_packages.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/scoped_packages.rs new file mode 100644 index 000000000..a686c63d1 --- /dev/null +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/scoped_packages.rs @@ -0,0 +1,34 @@ +//! + +use std::path::PathBuf; + +use oxc_resolver::{ResolveError, ResolveOptions, Resolver}; + +fn fixture() -> PathBuf { + super::fixture().join("scoped") +} + +#[test] +fn scoped_packages() -> Result<(), ResolveError> { + let f = fixture(); + + let options = + ResolveOptions { alias_fields: vec!["browser".into()], ..ResolveOptions::default() }; + + let resolver = Resolver::new(options); + + #[rustfmt::skip] + let pass = [ + ("main field should work", f.clone(), "@scope/pack1", f.join("./node_modules/@scope/pack1/main.js")), + ("browser field should work", f.clone(), "@scope/pack2", f.join("./node_modules/@scope/pack2/main.js")), + ("folder request should work", f.clone(), "@scope/pack2/lib", f.join("./node_modules/@scope/pack2/lib/index.js")) + ]; + + for (comment, path, request, expected) in pass { + let resolution = resolver.resolve(&f, request)?; + let resolved_path = resolution.path(); + assert_eq!(resolved_path, expected, "{comment} {path:?} {request}"); + } + + Ok(()) +}