feat(resolver): implement fully specified (#687)

This is for turning off ESM's forced extension functionality.
This commit is contained in:
Boshen 2023-08-04 17:50:43 +08:00 committed by GitHub
parent 702d5b0120
commit d21307827d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 148 additions and 42 deletions

View file

@ -20,7 +20,7 @@
| ✅ | 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 |
| | 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) |
| | 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 |
| ✅ | modules | ["node_modules"] | A list of directories to resolve modules from, can be absolute path or folder name |
@ -51,7 +51,7 @@ Crossed out test files are irrelevant.
- [x] extensions.test.js
- [x] fallback.test.js (need to fix a todo)
- [x] ~forEachBail.test.js~
- [ ] fullSpecified.test.js
- [x] fullSpecified.test.js
- [ ] getPaths.test.js
- [x] identifier.test.js (see unit test in `crates/oxc_resolver/src/request.rs`)
- [x] importsField.test.js

View file

@ -42,8 +42,6 @@ pub use crate::{
resolution::Resolution,
};
type ResolveState = Result<Option<CacheValue>, ResolveError>;
/// Resolver with the current operating system as the file system
pub type Resolver = ResolverGeneric<FileSystemOs>;
@ -53,6 +51,13 @@ pub struct ResolverGeneric<Fs> {
cache: Cache<Fs>,
}
type ResolveState = Result<Option<CacheValue>, ResolveError>;
#[derive(Debug, Default, Clone, Copy)]
struct ResolveContext {
fully_specified: bool,
}
impl<Fs: FileSystem> Default for ResolverGeneric<Fs> {
fn default() -> Self {
Self::new(ResolveOptions::default())
@ -80,13 +85,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
) -> Result<Resolution, ResolveError> {
let path = path.as_ref();
let request = Request::parse(request).map_err(ResolveError::Request)?;
let ctx = ResolveContext { fully_specified: self.options.fully_specified };
let cache_value = self.cache.value(path);
let cache_value = if let Some(path) =
self.load_alias(&cache_value, request.path.as_str(), &self.options.alias)?
{
path
} else {
let result = self.require(&cache_value, &request);
let result = self.require(&cache_value, &request, ctx);
if result.as_ref().is_err_and(ResolveError::is_not_found) {
if let Some(path) =
self.load_alias(&cache_value, request.path.as_str(), &self.options.fallback)?
@ -114,6 +120,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
&self,
cache_value: &CacheValue,
request: &Request,
ctx: ResolveContext,
) -> Result<CacheValue, ResolveError> {
let path = match request.path {
// 1. If X is a core module,
@ -123,15 +130,15 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
// a. set Y to be the file system root
RequestPath::Absolute(absolute_path) => {
if !self.options.prefer_relative && self.options.prefer_absolute {
if let Ok(path) = self.package_resolve(cache_value, absolute_path) {
if let Ok(path) = self.package_resolve(cache_value, absolute_path, ctx) {
return Ok(path);
}
}
self.load_roots(cache_value, absolute_path)
self.load_roots(cache_value, absolute_path, ctx)
}
// 3. If X begins with './' or '/' or '../'
RequestPath::Relative(relative_path) => {
self.require_relative(cache_value, relative_path)
self.require_relative(cache_value, relative_path, ctx)
}
// 4. If X begins with '#'
RequestPath::Hash(specifier) => {
@ -143,11 +150,11 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
// Set resolved the result of PACKAGE_RESOLVE(specifier, parentURL).
RequestPath::Bare(bare_specifier) => {
if self.options.prefer_relative {
if let Ok(path) = self.require_relative(cache_value, bare_specifier) {
if let Ok(path) = self.require_relative(cache_value, bare_specifier, ctx) {
return Ok(path);
}
}
self.package_resolve(cache_value, bare_specifier)
self.package_resolve(cache_value, bare_specifier, ctx)
}
}?;
@ -164,17 +171,18 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
&self,
cache_value: &CacheValue,
request: &str,
ctx: ResolveContext,
) -> Result<CacheValue, ResolveError> {
let path = cache_value.path().normalize_with(request);
let cache_value = self.cache.value(&path);
// a. LOAD_AS_FILE(Y + X)
if !request.ends_with('/') {
if let Some(path) = self.load_as_file(&cache_value)? {
if let Some(path) = self.load_as_file(&cache_value, ctx)? {
return Ok(path);
}
}
// b. LOAD_AS_DIRECTORY(Y + X)
if let Some(path) = self.load_as_directory(&cache_value)? {
if let Some(path) = self.load_as_directory(&cache_value, ctx)? {
return Ok(path);
}
// c. THROW "not found"
@ -186,40 +194,54 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
&self,
cache_value: &CacheValue,
request: &str,
ctx: ResolveContext,
) -> Result<CacheValue, ResolveError> {
let (_, subpath) = Self::parse_package_specifier(request);
let mut ctx = ctx;
if subpath.is_empty() {
ctx.fully_specified = false;
}
let dirname = self.cache.dirname(cache_value);
// 5. LOAD_PACKAGE_SELF(X, dirname(Y))
if let Some(path) = self.load_package_self(dirname, request)? {
return Ok(path);
}
// 6. LOAD_NODE_MODULES(X, dirname(Y))
if let Some(path) = self.load_node_modules(dirname, request)? {
if let Some(path) = self.load_node_modules(dirname, request, ctx)? {
return Ok(path);
}
// 7. THROW "not found"
Err(ResolveError::NotFound(cache_value.to_path_buf().into_boxed_path()))
}
fn load_as_file(&self, cache_value: &CacheValue) -> ResolveState {
fn load_as_file(&self, cache_value: &CacheValue, ctx: ResolveContext) -> ResolveState {
// enhanced-resolve feature: extension_alias
if let Some(path) = self.load_extension_alias(cache_value)? {
if let Some(path) = self.load_extension_alias(cache_value, ctx)? {
return Ok(Some(path));
}
// 1. If X is a file, load X as its file extension format. STOP
// let cache_value = self.cache.cache_value(&path);
if let Some(path) = self.load_alias_or_file(cache_value)? {
return Ok(Some(path));
}
// 2. If X.js is a file, load X.js as JavaScript text. STOP
// 3. If X.json is a file, parse X.json to a JavaScript Object. STOP
// 4. If X.node is a file, load X.node as binary addon. STOP
if let Some(path) = self.load_extensions(cache_value, &self.options.extensions)? {
if let Some(path) = self.load_extensions(cache_value, &self.options.extensions, ctx)? {
return Ok(Some(path));
}
Ok(None)
}
fn load_extensions(&self, cache_value: &CacheValue, extensions: &[String]) -> ResolveState {
fn load_extensions(
&self,
cache_value: &CacheValue,
extensions: &[String],
ctx: ResolveContext,
) -> ResolveState {
if ctx.fully_specified {
return Ok(None);
}
let mut path_with_extension = cache_value.path().to_path_buf();
for extension in extensions {
path_with_extension.set_extension(extension);
@ -239,7 +261,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
}
}
fn load_index(&self, cache_value: &CacheValue) -> ResolveState {
fn load_index(&self, cache_value: &CacheValue, ctx: ResolveContext) -> ResolveState {
for main_file in &self.options.main_files {
let main_path = cache_value.path().join(main_file);
let cache_value = self.cache.value(&main_path);
@ -251,7 +273,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
// 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
if let Some(path) = self.load_extensions(&cache_value, &self.options.extensions)? {
if let Some(path) = self.load_extensions(&cache_value, &self.options.extensions, ctx)? {
return Ok(Some(path));
}
}
@ -271,7 +293,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
Ok(None)
}
fn load_as_directory(&self, cache_value: &CacheValue) -> ResolveState {
fn load_as_directory(&self, cache_value: &CacheValue, ctx: ResolveContext) -> ResolveState {
// TODO: Only package.json is supported, so warn about having other values
// Checking for empty files is needed for omitting checks on package.json
// 1. If X/package.json is a file,
@ -284,28 +306,29 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
let main_field_path = cache_value.path().normalize_with(main_field);
// d. LOAD_AS_FILE(M)
let cache_value = self.cache.value(&main_field_path);
if let Some(path) = self.load_as_file(&cache_value)? {
if let Some(path) = self.load_as_file(&cache_value, ctx)? {
return Ok(Some(path));
}
// e. LOAD_INDEX(M)
if let Some(path) = self.load_index(&cache_value)? {
if let Some(path) = self.load_index(&cache_value, ctx)? {
return Ok(Some(path));
}
// f. LOAD_INDEX(X) DEPRECATED
// g. THROW "not found"
return Err(ResolveError::NotFound(main_field_path.into_boxed_path()));
}
if let Some(path) = self.load_index(cache_value)? {
return Ok(Some(path));
}
}
}
// 2. LOAD_INDEX(X)
self.load_index(cache_value)
self.load_index(cache_value, ctx)
}
fn load_node_modules(&self, cache_value: &CacheValue, request: &str) -> ResolveState {
fn load_node_modules(
&self,
cache_value: &CacheValue,
request: &str,
ctx: ResolveContext,
) -> ResolveState {
// 1. let DIRS = NODE_MODULES_PATHS(START)
// Use a buffer to reduce total memory allocation.
let mut node_module_path = cache_value.path().to_path_buf();
@ -323,13 +346,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
let cache_value = self.cache.value(&node_module_file);
// b. LOAD_AS_FILE(DIR/X)
if !request.ends_with('/') {
if let Some(path) = self.load_as_file(&cache_value)? {
if let Some(path) = self.load_as_file(&cache_value, ctx)? {
return Ok(Some(path));
}
}
// c. LOAD_AS_DIRECTORY(DIR/X)
if cache_value.is_dir(&self.cache.fs) {
if let Some(path) = self.load_as_directory(&cache_value)? {
if let Some(path) = self.load_as_directory(&cache_value, ctx)? {
return Ok(Some(path));
}
}
@ -458,7 +481,8 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
debug_assert!(package_json.path.file_name().is_some_and(|x| x == "package.json"));
// TODO: Do we need to pass query and fragment?
let cache_value = self.cache.value(package_json.path.parent().unwrap());
return self.require(&cache_value, &request).map(Some);
let ctx = ResolveContext::default();
return self.require(&cache_value, &request, ctx).map(Some);
}
}
Ok(None)
@ -478,7 +502,8 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
};
let new_request =
Request::parse(&new_request).map_err(ResolveError::Request)?;
match self.require(cache_value, &new_request) {
let ctx = ResolveContext::default();
match self.require(cache_value, &new_request, ctx) {
Err(ResolveError::NotFound(_)) => { /* noop */ }
Ok(path) => return Ok(Some(path)),
Err(err) => return Err(err),
@ -505,14 +530,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
/// # Errors
///
/// * [ResolveError::ExtensionAlias]: When all of the aliased extensions are not found
fn load_extension_alias(&self, cache_value: &CacheValue) -> ResolveState {
fn load_extension_alias(&self, cache_value: &CacheValue, ctx: ResolveContext) -> ResolveState {
let Some(path_extension) = cache_value.path().extension() else { return Ok(None) };
let Some((_, extensions)) =
self.options.extension_alias.iter().find(|(ext, _)| OsStr::new(ext) == path_extension)
else {
return Ok(None);
};
if let Some(path) = self.load_extensions(cache_value, extensions)? {
if let Some(path) = self.load_extensions(cache_value, extensions, ctx)? {
return Ok(Some(path));
}
Err(ResolveError::ExtensionAlias)
@ -522,15 +547,18 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
&self,
cache_value: &CacheValue,
request: &str,
ctx: ResolveContext,
) -> Result<CacheValue, ResolveError> {
debug_assert!(request.starts_with('/'));
if self.options.roots.is_empty() {
let cache_value = self.cache.value(Path::new("/"));
return self.package_resolve(&cache_value, request);
return self.package_resolve(&cache_value, request, ctx);
}
for root in &self.options.roots {
let cache_value = self.cache.value(root);
if let Ok(path) = self.require_relative(&cache_value, request.trim_start_matches('/')) {
if let Ok(path) =
self.require_relative(&cache_value, request.trim_start_matches('/'), ctx)
{
return Ok(path);
}
}
@ -797,7 +825,9 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
normalize_string_target(target_key, target, pattern_match, package_url)?;
let package_url = self.cache.value(package_url);
// // 3. Return PACKAGE_RESOLVE(target, packageURL + "/").
return self.package_resolve(&package_url, &target).map(Some);
return self
.package_resolve(&package_url, &target, ResolveContext::default())
.map(Some);
}
// 2. If target split on "/" or "\" contains any "", ".", "..", or "node_modules" segments after the first "." segment, case insensitive and including percent encoded variants, throw an Invalid Package Target error.

View file

@ -68,7 +68,9 @@ pub struct ResolveOptions {
/// Default `[]`
pub fallback: Alias,
/// 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)
/// 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).
///
/// See also webpack configuration [resolve.fullySpecified](https://webpack.js.org/configuration/module/#resolvefullyspecified)
///
/// Default `false`
pub fully_specified: bool,

View file

@ -109,7 +109,7 @@ fn exports_not_browser_field2() {
}
#[test]
#[ignore = "fullSpecified"]
#[ignore = "fully_specified"]
// should resolve extension without fullySpecified
fn extension_without_fully_specified() {
let f2 = super::fixture().join("exports-field2");
@ -181,7 +181,7 @@ fn extension_alias_3() {
}
#[test]
#[ignore]
#[ignore = "fully_specified"]
fn extension_alias_throw_error() {
let f = super::fixture().join("exports-field-and-extension-alias");
@ -195,8 +195,9 @@ fn extension_alias_throw_error() {
#[rustfmt::skip]
let fail = [
// These two test cases are exactly the same in enhanced-resolve
// https://github.com/webpack/enhanced-resolve/blob/a998c7d218b7a9ec2461fc4fddd1ad5dd7687485/test/exportsField.test.js#L2976-L3024
("should throw error with the `extensionAlias` option", f.clone(), "pkg/string.js", ResolveError::PackagePathNotExported("node_modules/pkg/dist/string.js".to_string())),
// They are exactly the same in enhanced-resolve
("should throw error with the `extensionAlias` option #2", f, "pkg/string.js", ResolveError::PackagePathNotExported("node_modules/pkg/dist/string.js".to_string())),
];

View file

@ -0,0 +1,72 @@
//! https://github.com/webpack/enhanced-resolve/blob/main/test/fullSpecified.test.js
use std::path::PathBuf;
use crate::{AliasValue, ResolveOptions, ResolverGeneric};
use super::memory_fs::MemoryFS;
#[test]
#[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
fn test() {
use crate::Resolution;
let file_system = MemoryFS::new(&[
("/a/node_modules/package1/index.js", ""),
("/a/node_modules/package1/file.js", ""),
("/a/node_modules/package2/package.json", r#"{"main":"a"}"#),
("/a/node_modules/package2/a.js", ""),
("/a/node_modules/package3/package.json", r#"{"main":"dir"}"#),
("/a/node_modules/package3/dir/index.js", ""),
("/a/node_modules/package4/package.json", r#"{"browser":{"./a.js":"./b"}}"#),
("/a/node_modules/package4/a.js", ""),
("/a/node_modules/package4/b.js", ""),
("/a/abc.js", ""),
("/a/dir/index.js", ""),
("/a/index.js", ""),
]);
let options = ResolveOptions {
alias: vec![
("alias1".into(), vec![AliasValue::Path("/a/abc".into())]),
("alias2".into(), vec![AliasValue::Path("/a".into())]),
],
alias_fields: vec!["browser".into()],
fully_specified: true,
..ResolveOptions::default()
};
let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(options, file_system);
let failing_resolves = [
("no extensions", "./abc"),
("no extensions (absolute)", "/a/abc"),
("no extensions in packages", "package1/file"),
("no directories", "."),
("no directories 2", "./"),
("no directories in packages", "package3/dir"),
("no extensions in packages 2", "package3/a"),
];
for (comment, request) in failing_resolves {
let resolution = resolver.resolve("/a", request);
assert!(resolution.is_err(), "{comment} {request}");
}
let successful_resolves = [
("fully relative", "./abc.js", "/a/abc.js"),
("fully absolute", "/a/abc.js", "/a/abc.js"),
("fully relative in package", "package1/file.js", "/a/node_modules/package1/file.js"),
("extensions in mainFiles", "package1", "/a/node_modules/package1/index.js"),
("extensions in mainFields", "package2", "/a/node_modules/package2/a.js"),
("extensions in alias", "alias1", "/a/abc.js"),
("directories in alias", "alias2", "/a/index.js"),
("directories in packages", "package3", "/a/node_modules/package3/dir/index.js"),
("extensions in aliasFields", "package4/a.js", "/a/node_modules/package4/b.js"),
];
for (comment, request, expected) in successful_resolves {
let resolution = resolver.resolve("/a", request).map(Resolution::full_path);
assert_eq!(resolution, Ok(PathBuf::from(expected)), "{comment} {request}");
}
}

View file

@ -4,6 +4,7 @@ mod exports_field;
mod extension_alias;
mod extensions;
mod fallback;
mod full_specified;
mod imports_field;
mod incorrect_description_file;
mod memory_fs;