feat(resolver): implement scoped packages (#558)

This commit is contained in:
Boshen 2023-07-16 20:36:47 +08:00 committed by GitHub
parent e0a17ace8f
commit b2152ec050
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 115 additions and 20 deletions

View file

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

View file

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

View file

@ -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<String>,
/// 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()],

View file

@ -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<BrowserField<'a>>,
}
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<Self, Self::Error> {
serde_json::from_str(s)
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)?;
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) => {
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 <https://github.com/defunctzombie/package-browser-field-spec#alternate-main---basic>
BrowserField::String(_) => None,
}
}
fn resolve_browser_field(&self, key: &str, value: &str, path: &Path) -> Option<PathBuf> {
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())
}
}

View file

@ -16,7 +16,7 @@ pub enum RequestPath<'a> {
Absolute(&'a str),
/// `./path`, `../path`
Relative(&'a str),
/// `path`
/// `path`, `@scope/path`
Module(&'a str),
}

View file

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

View file

@ -0,0 +1,34 @@
//! <https://github.com/webpack/enhanced-resolve/blob/main/test/scoped-packages.test.js>
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(())
}