From 2e3934db49acedf5694bb6ec54329bc6198c14d9 Mon Sep 17 00:00:00 2001 From: Boshen Date: Wed, 2 Aug 2023 15:54:07 +0800 Subject: [PATCH] feat(resolver): imports field (#681) --- crates/oxc_resolver/README.md | 2 +- crates/oxc_resolver/src/error.rs | 2 + crates/oxc_resolver/src/lib.rs | 139 +- crates/oxc_resolver/src/package_json.rs | 26 +- .../enhanced_resolve/test/exports_field.rs | 13 +- .../enhanced_resolve/test/imports_field.rs | 1299 +++++++++++++++++ .../tests/enhanced_resolve/test/mod.rs | 1 + 7 files changed, 1421 insertions(+), 61 deletions(-) create mode 100644 crates/oxc_resolver/tests/enhanced_resolve/test/imports_field.rs diff --git a/crates/oxc_resolver/README.md b/crates/oxc_resolver/README.md index 383d3bffd..d161e5a53 100644 --- a/crates/oxc_resolver/README.md +++ b/crates/oxc_resolver/README.md @@ -52,7 +52,7 @@ Crossed out test files are irrelevant. - [ ] fullSpecified.test.js - [ ] getPaths.test.js - [x] identifier.test.js (see unit test in `crates/oxc_resolver/src/request.rs`) -- [ ] importsField.test.js +- [x] importsField.test.js - [x] incorrect-description-file.test.js (need to add ctx.fileDependencies) - [ ] missing.test.js - [ ] path.test.js diff --git a/crates/oxc_resolver/src/error.rs b/crates/oxc_resolver/src/error.rs index b7302dbdb..3adbe8c83 100644 --- a/crates/oxc_resolver/src/error.rs +++ b/crates/oxc_resolver/src/error.rs @@ -49,6 +49,8 @@ pub enum ResolveError { // TODO: Expecting folder to folder mapping. "./data/timezones" should end with "/" InvalidPackageConfigDirectory(PathBuf), + + PackageImportNotDefined(String), } #[derive(Debug, Clone, Eq, PartialEq)] diff --git a/crates/oxc_resolver/src/lib.rs b/crates/oxc_resolver/src/lib.rs index 8f4875134..f6bd74040 100644 --- a/crates/oxc_resolver/src/lib.rs +++ b/crates/oxc_resolver/src/lib.rs @@ -27,14 +27,14 @@ use std::{ use crate::{ cache::{Cache, CacheValue}, file_system::FileSystemOs, - package_json::{ExportsKey, MatchObject, PackageJson}, + package_json::{ExportsKey, PackageJson}, request::{Request, RequestPath}, }; pub use crate::{ error::{JSONError, ResolveError}, file_system::{FileMetadata, FileSystem}, options::{Alias, AliasValue, ResolveOptions}, - package_json::ExportsField, + package_json::{ExportsField, MatchObject}, path::PathUtil, resolution::Resolution, }; @@ -131,8 +131,10 @@ impl ResolverGeneric { self.require_relative(cache_value, relative_path) } // 4. If X begins with '#' - // a. LOAD_PACKAGE_IMPORTS(X, dirname(Y)) - RequestPath::Hash(hash_path) => self.package_resolve(cache_value, hash_path), + RequestPath::Hash(specifier) => { + // a. LOAD_PACKAGE_IMPORTS(X, dirname(Y)) + self.package_imports_resolve(cache_value, specifier) + } // (ESM) 5. Otherwise, // Note: specifier is now a bare specifier. // Set resolved the result of PACKAGE_RESOLVE(specifier, parentURL). @@ -421,11 +423,11 @@ impl ResolverGeneric { // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE), // "." + X.slice("name".length), `package.json` "exports", ["node", "require"]) // defined in the ESM resolver. - let path = package_json.path.parent().unwrap(); + let package_url = package_json.path.parent().unwrap(); // Note: The subpath is not prepended with a dot on purpose // because `package_exports_resolve` matches subpath without the leading dot. if let Some(path) = self.package_exports_resolve( - path, + package_url, subpath, &package_json.exports, &self.options.condition_names, @@ -606,9 +608,13 @@ impl ResolverGeneric { // Note: `package_imports_exports_resolve` does not require the leading dot. let match_key = subpath; // 2. Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE( matchKey, exports, packageURL, false, conditions). - if let Some(path) = - self.package_imports_exports_resolve(match_key, exports, package_url, conditions)? - { + if let Some(path) = self.package_imports_exports_resolve( + match_key, + exports, + package_url, + /* is_imports */ false, + conditions, + )? { // 3. If resolved is not null or undefined, return resolved. return Ok(Some(path)); } @@ -617,12 +623,52 @@ impl ResolverGeneric { Err(ResolveError::PackagePathNotExported(format!(".{subpath}"))) } + /// PACKAGE_IMPORTS_RESOLVE(specifier, parentURL, conditions) + fn package_imports_resolve( + &self, + cache_value: &CacheValue, + specifier: &str, + ) -> Result { + // 1. Assert: specifier begins with "#". + debug_assert!(specifier.starts_with('#'), "{specifier}"); + // 2. If specifier is exactly equal to "#" or starts with "#/", then + if specifier == "#" || specifier.starts_with("#/") { + // 1. Throw an Invalid Module Specifier error. + return Err(ResolveError::InvalidModuleSpecifier(specifier.to_string())); + } + // 3. Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL). + // 4. If packageURL is not null, then + if let Some(package_json) = cache_value.find_package_json(&self.cache.fs)? { + // 1. Let pjson be the result of READ_PACKAGE_JSON(packageURL). + // 2. If pjson.imports is a non-null Object, then + if !package_json.imports.is_empty() { + // 1. Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE( specifier, pjson.imports, packageURL, true, conditions). + let package_url = package_json.path.parent().unwrap(); + if let Some(path) = self.package_imports_exports_resolve( + specifier, + &package_json.imports, + package_url, + /* is_imports */ true, + &self.options.condition_names, + )? { + // 2. If resolved is not null or undefined, return resolved. + return Ok(path); + } + } + } + // 5. Throw a Package Import Not Defined error. + Err(ResolveError::PackageImportNotDefined(specifier.to_string())) + } + /// PACKAGE_IMPORTS_EXPORTS_RESOLVE(matchKey, matchObj, packageURL, isImports, conditions) - fn package_imports_exports_resolve( + /// + /// # Errors + pub fn package_imports_exports_resolve( &self, match_key: &str, match_obj: &MatchObject, package_url: &Path, + is_imports: bool, conditions: &[String], ) -> ResolveState { // enhanced_resolve behaves differently, it throws @@ -640,7 +686,7 @@ impl ResolverGeneric { match_key, target, None, - /* is_imports */ false, + is_imports, conditions, ); } @@ -690,7 +736,7 @@ impl ResolverGeneric { best_key, best_target, Some(best_match), - /* is_imports */ false, + is_imports, conditions, ); } @@ -708,6 +754,32 @@ impl ResolverGeneric { is_imports: bool, conditions: &[String], ) -> ResolveState { + fn normalize_string_target<'a>( + target_key: &'a str, + target: &'a str, + pattern_match: Option<&'a str>, + package_url: &Path, + ) -> Result, ResolveError> { + let target = if let Some(pattern_match) = pattern_match { + if !target_key.contains('*') && !target.contains('*') { + // enhanced_resolve behaviour + // TODO: [DEP0148] DeprecationWarning: Use of deprecated folder mapping "./dist/" in the "exports" field module resolution of the package at xxx/package.json. + if target_key.ends_with('/') && target.ends_with('/') { + Cow::Owned(format!("{target}{pattern_match}")) + } else { + return Err(ResolveError::InvalidPackageConfigDirectory( + package_url.join("package.json"), + )); + } + } else { + Cow::Owned(target.replace('*', pattern_match)) + } + } else { + Cow::Borrowed(target) + }; + Ok(target) + } + match target { ExportsField::None => {} // 1. If target is a String, then @@ -723,38 +795,23 @@ impl ResolverGeneric { } // 2. If patternMatch is a String, then // 1. Return PACKAGE_RESOLVE(target with every instance of "*" replaced by patternMatch, packageURL + "/"). - // 3. Return PACKAGE_RESOLVE(target, packageURL + "/"). + let target = + 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); } - // 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. - let target = if let Some(pattern_match) = pattern_match { - if !target_key.contains('*') && !target.contains('*') { - // enhanced_resolve behaviour - // TODO: [DEP0148] DeprecationWarning: Use of deprecated folder mapping "./dist/" in the "exports" field module resolution of the package at xxx/package.json. - if target_key.ends_with('/') && target.ends_with('/') { - Cow::Owned(format!("{target}{pattern_match}")) - } else { - return Err(ResolveError::InvalidPackageConfigDirectory( - package_url.join("package.json"), - )); - } - } else { - // if !target.contains('*') && target.ends_with('/') { - // Cow::Owned(format!("{target}{pattern_match}")) - // } else { - Cow::Owned(target.replace('*', pattern_match)) - } - // } - } else { - Cow::Borrowed(target) - }; - if Path::new(target.as_str()).is_invalid_exports_target() { - return Err(ResolveError::InvalidPackageTarget(target.to_string())); - } + // 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. // 3. Let resolvedTarget be the URL resolution of the concatenation of packageURL and target. - let resolved_target = package_url.join(target.as_str()).normalize(); // 4. Assert: resolvedTarget is contained in packageURL. // 5. If patternMatch is null, then + let target = + normalize_string_target(target_key, target, pattern_match, package_url)?; + if Path::new(target.as_ref()).is_invalid_exports_target() { + return Err(ResolveError::InvalidPackageTarget(target.to_string())); + } + let resolved_target = package_url.join(target.as_ref()).normalize(); // 6. If patternMatch split on "/" or "\" contains any "", ".", "..", or "node_modules" segments, case insensitive and including percent encoded variants, throw an Invalid Module Specifier error. // 7. Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch. return Ok(Some(self.cache.value(&resolved_target))); @@ -785,7 +842,7 @@ impl ResolverGeneric { target_key, target_value, pattern_match, - /* is_imports */ false, + is_imports, conditions, ); // 3. If resolved is equal to undefined, continue the loop. @@ -816,7 +873,7 @@ impl ResolverGeneric { target_key, target_value, pattern_match, - /* is_imports */ false, + is_imports, conditions, ); diff --git a/crates/oxc_resolver/src/package_json.rs b/crates/oxc_resolver/src/package_json.rs index 6b37accec..7344915ab 100644 --- a/crates/oxc_resolver/src/package_json.rs +++ b/crates/oxc_resolver/src/package_json.rs @@ -38,19 +38,18 @@ pub struct PackageJson { #[serde(default)] pub exports: 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. + /// In addition to the "exports" field, there is a package "imports" field to create private mappings that only apply to import specifiers from within the package itself. + /// + /// + #[serde(default)] + pub imports: MatchObject, + + /// 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. /// /// pub browser: Option, } -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum BrowserField { - String(String), - Map(FxIndexMap), -} - /// `matchObj` defined in `PACKAGE_IMPORTS_EXPORTS_RESOLVE` pub type MatchObject = FxIndexMap; @@ -86,8 +85,8 @@ impl From<&str> for ExportsKey { Self::Main } else if key.starts_with("./") { Self::Pattern(key.trim_start_matches('.').to_string()) - } else if let Some(key) = key.strip_prefix('#') { - Self::Hash(key.to_string()) + } else if key.starts_with('#') { + Self::Pattern(key.to_string()) } else { Self::CustomCondition(key.to_string()) } @@ -104,6 +103,13 @@ impl<'a, 'de: 'a> Deserialize<'de> for ExportsKey { } } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum BrowserField { + String(String), + Map(FxIndexMap), +} + impl PackageJson { pub fn parse(path: PathBuf, json: &str) -> Result { let mut package_json: Self = serde_json::from_str(json)?; diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs index 3d0cafea9..3cea0769d 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/exports_field.rs @@ -1,10 +1,10 @@ //! https://github.com/webpack/enhanced-resolve/blob/main/test/exportsField.test.js //! -//! The resolution tests are at the bottom of the file. +//! The huge exports field test cases are at the bottom of this file. use oxc_resolver::{ExportsField, PathUtil, Resolution, ResolveError, ResolveOptions, Resolver}; use serde_json::json; -use std::path::{Path, PathBuf}; +use std::path::Path; #[test] fn test() { @@ -235,7 +235,7 @@ fn exports_field(value: serde_json::Value) -> ExportsField { } #[test] -fn entry_points() { +fn test_cases() { let test_cases = [ TestCase { name: "sample #1", @@ -2456,12 +2456,7 @@ fn entry_points() { ); } else { for expect in expect { - assert_eq!( - resolved, - Ok(Some(PathBuf::from(expect).normalize())), - "{}", - &case.name - ); + assert_eq!(resolved, Ok(Some(Path::new(expect).normalize())), "{}", &case.name); } } } else { diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/imports_field.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/imports_field.rs new file mode 100644 index 000000000..7ea4658b7 --- /dev/null +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/imports_field.rs @@ -0,0 +1,1299 @@ +//! https://github.com/webpack/enhanced-resolve/blob/main/test/importsField.test.js +//! +//! The huge imports field test cases are at the bottom of this file. + +use serde_json::json; + +use oxc_resolver::{MatchObject, PathUtil, Resolution, ResolveError, ResolveOptions, Resolver}; +use std::path::Path; + +#[test] +fn test() { + let f = super::fixture().join("imports-field"); + let f2 = super::fixture().join("imports-exports-wildcard/node_modules/m/"); + + let resolver = Resolver::new(ResolveOptions { + extensions: vec![".js".into()], + main_files: vec!["index.js".into()], + condition_names: vec!["webpack".into()], + ..ResolveOptions::default() + }); + + #[rustfmt::skip] + let pass = [ + ("should resolve using imports field instead of self-referencing", f.clone(), "#imports-field", f.join("b.js")), + ("should resolve using imports field instead of self-referencing for a subpath", f.join("dir"), "#imports-field", f.join("b.js")), + ("should resolve package #1", f.clone(), "#a/dist/main.js", f.join("node_modules/a/lib/lib2/main.js")), + ("should resolve package #3", f.clone(), "#ccc/index.js", f.join("node_modules/c/index.js")), + ("should resolve package #4", f.clone(), "#c", f.join("node_modules/c/index.js")), + ("should resolve with wildcard pattern", f2.clone(), "#internal/i.js", f2.join("src/internal/i.js")), + ]; + + for (comment, path, request, expected) in pass { + let resolved_path = resolver.resolve(&path, request).map(Resolution::full_path); + assert_eq!(resolved_path, Ok(expected), "{comment} {path:?} {request}"); + } + + // Note added: + // * should resolve absolute path as an imports field target + // * should log the correct info + + #[rustfmt::skip] + let fail = [ + ("should disallow resolve out of package scope", f.clone(), "#b", ResolveError::InvalidPackageTarget("../b.js".to_string())), + ("should resolve package #2", f, "#a", ResolveError::PackageImportNotDefined("#a".to_string())), + ]; + + for (comment, path, request, error) in fail { + let resolution = resolver.resolve(&path, request); + assert_eq!(resolution, Err(error), "{comment} {path:?} {request}"); + } +} + +#[test] +#[ignore = "imports field name"] +// field name path #1 - #2 +fn field_name() {} + +// Small script for generating the test cases from enhanced_resolve +// for (c of testCases) { +// console.log("TestCase {") +// console.log(`name: ${JSON.stringify(c.name)},`) +// if (c.expect instanceof Error) { +// console.log(`expect: None,`) +// } else { +// console.log(`expect: Some(vec!${JSON.stringify(c.expect)}),`) +// } +// console.log(`imports_field: imports_field(json!(${JSON.stringify(c.suite[0], null, 2)})),`) +// console.log(`request: "${c.suite[1]}",`) +// console.log(`condition_names: vec!${JSON.stringify(c.suite[2])},`) +// console.log("},") +// } + +struct TestCase { + name: &'static str, + expect: Option>, + imports_field: MatchObject, + request: &'static str, + condition_names: Vec<&'static str>, +} + +#[allow(clippy::needless_pass_by_value)] +fn imports_field(value: serde_json::Value) -> MatchObject { + let s = serde_json::to_string(&value).unwrap(); + serde_json::from_str(&s).unwrap() +} + +#[test] +#[allow(clippy::too_many_lines)] +fn test_cases() { + let test_cases = [ + TestCase { + name: "sample #1", + expect: Some(vec!["./dist/test/file.js"]), + imports_field: imports_field(json!({ + "#abc/": { + "import": [ + "./dist/", + "./src/" + ], + "webpack": "./wp/" + }, + "#abc": "./main.js" + })), + request: "#abc/test/file.js", + condition_names: vec!["import", "webpack"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "sample #1", + expect: Some(vec!["./src/test/file.js"]), + imports_field: imports_field(json!({ + "#abc/": { + "import": [ + "./src/" + ], + "webpack": "./wp/" + }, + "#abc": "./main.js" + })), + request: "#abc/test/file.js", + condition_names: vec!["import", "webpack"], + }, + TestCase { + name: "sample #2", + expect: Some(vec!["./data/timezones/pdt.mjs"]), + imports_field: imports_field(json!({ + "#1/timezones/": "./data/timezones/" + })), + request: "#1/timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "sample #3", + expect: Some(vec!["./data/timezones/timezones/pdt.mjs"]), + imports_field: imports_field(json!({ + "#aaa/": "./data/timezones/", + "#a/": "./data/timezones/" + })), + request: "#a/timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "sample #4", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/lib/": { + "browser": [ + "./browser/" + ] + }, + "#a/dist/index.js": { + "node": "./index.js" + } + })), + request: "#a/dist/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "sample #5", + expect: Some(vec!["./browser/index.js"]), + imports_field: imports_field(json!({ + "#a/lib/": { + "browser": [ + "./browser/" + ] + }, + "#a/dist/index.js": { + "node": "./index.js", + "default": "./browser/index.js" + } + })), + request: "#a/dist/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "sample #6", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/dist/a": "./dist/index.js" + })), + request: "#a/dist/aaa", + condition_names: vec![], + }, + TestCase { + name: "sample #7", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/a/a/": "./dist/index.js" + })), + request: "#a/a/a", + condition_names: vec![], + }, + TestCase { + name: "sample #8", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a": "./index.js" + })), + request: "#a/timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "sample #9", + expect: Some(vec!["./main.js"]), + imports_field: imports_field(json!({ + "#a/index.js": "./main.js" + })), + request: "#a/index.js", + condition_names: vec![], + }, + TestCase { + name: "sample #10", + expect: Some(vec!["./ok.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/#foo", + condition_names: vec![], + }, + TestCase { + name: "sample #11", + expect: Some(vec!["./ok.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/bar#foo", + condition_names: vec![], + }, + TestCase { + name: "sample #12", + expect: Some(vec!["./ok.js#abc"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/#zapp/ok.js#abc", + condition_names: vec![], + }, + TestCase { + name: "sample #13", + expect: Some(vec!["./ok.js?abc"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/#zapp/ok.js?abc", + condition_names: vec![], + }, + TestCase { + name: "sample #14", + expect: Some(vec!["./🎉.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/#zapp/🎉.js", + condition_names: vec![], + }, + TestCase { + name: "sample #15", + expect: Some(vec!["./%F0%9F%8E%89.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/#zapp/%F0%9F%8E%89.js", + condition_names: vec![], + }, + TestCase { + name: "sample #16", + expect: Some(vec!["./ok.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/🎉", + condition_names: vec![], + }, + TestCase { + name: "sample #17", + expect: Some(vec!["./other.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/%F0%9F%8E%89", + condition_names: vec![], + }, + TestCase { + name: "sample #18", + expect: Some(vec!["./ok.js"]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/module", + condition_names: vec![], + }, + TestCase { + name: "sample #19", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/module#foo", + condition_names: vec![], + }, + TestCase { + name: "sample #20", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/#foo": "./ok.js", + "#a/module": "./ok.js", + "#a/🎉": "./ok.js", + "#a/%F0%9F%8E%89": "./other.js", + "#a/bar#foo": "./ok.js", + "#a/#zapp/": "./" + })), + request: "#a/module?foo", + condition_names: vec![], + }, + TestCase { + name: "sample #21", + expect: Some(vec!["./d?e?f"]), + imports_field: imports_field(json!({ + "#a/a?b?c/": "./" + })), + request: "#a/a?b?c/d?e?f", + condition_names: vec![], + }, + TestCase { + name: "sample #22", + // We throw InvalidPackageTarget + expect: None, + // expect: Some(vec!["/user/a/index"]), + imports_field: imports_field(json!({ + "#a/": "/user/a/" + })), + request: "#a/index", + condition_names: vec![], + }, + TestCase { + name: "path tree edge case #1", + expect: Some(vec!["./A/b/d.js"]), + imports_field: imports_field(json!({ + "#a/": "./A/", + "#a/b/c": "./c.js" + })), + request: "#a/b/d.js", + condition_names: vec![], + }, + TestCase { + name: "path tree edge case #2", + expect: Some(vec!["./A/c.js"]), + imports_field: imports_field(json!({ + "#a/": "./A/", + "#a/b": "./b.js" + })), + request: "#a/c.js", + condition_names: vec![], + }, + TestCase { + name: "path tree edge case #3", + expect: Some(vec!["./A/b/c/d.js"]), + imports_field: imports_field(json!({ + "#a/": "./A/", + "#a/b/c/d": "./c.js" + })), + request: "#a/b/c/d.js", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #1", + expect: Some(vec!["./dist/index.js"]), + imports_field: imports_field(json!({ + "#a": "./dist/index.js" + })), + request: "#a", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #2", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": "./" + })), + request: "#a", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #3", + expect: Some(vec!["./dist/a.js"]), + imports_field: imports_field(json!({ + "#a/": "./dist/", + "#a/index.js": "./dist/a.js" + })), + request: "#a/index.js", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #4", + expect: Some(vec!["./index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": [ + "./browser/" + ] + }, + "#a/index.js": { + "browser": "./index.js" + } + })), + request: "#a/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "Direct mapping #5", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": [ + "./browser/" + ] + }, + "#a/index.js": { + "node": "./node.js" + } + })), + request: "#a/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "Direct mapping #6", + expect: Some(vec!["./index.js"]), + imports_field: imports_field(json!({ + "#a": { + "browser": "./index.js", + "node": "./src/node/index.js", + "default": "./src/index.js" + } + })), + request: "#a", + condition_names: vec!["browser"], + }, + TestCase { + name: "Direct mapping #7", + expect: None, + imports_field: imports_field(json!({ + "#a": { + "default": "./src/index.js", + "browser": "./index.js", + "node": "./src/node/index.js" + } + })), + request: "#a", + condition_names: vec!["browser"], + }, + TestCase { + name: "Direct mapping #8", + expect: Some(vec!["./src/index.js"]), + imports_field: imports_field(json!({ + "#a": { + "browser": "./index.js", + "node": "./src/node/index.js", + "default": "./src/index.js" + } + })), + request: "#a", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #9", + expect: Some(vec!["./index"]), + imports_field: imports_field(json!({ + "#a": "./index" + })), + request: "#a", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #10", + expect: Some(vec!["./index.js"]), + imports_field: imports_field(json!({ + "#a/index": "./index.js" + })), + request: "#a/index", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #11", + // We throw InvalidPackageTarget + // expect: Some(vec!["b"]), + expect: None, + imports_field: imports_field(json!({ + "#a": "b" + })), + request: "#a", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #12", + // We throw InvalidPackageTarget + // expect: Some(vec!["b/index"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": "b/" + })), + request: "#a/index", + condition_names: vec![], + }, + TestCase { + name: "Direct mapping #13", + // We throw InvalidPackageTarget + // expect: Some(vec!["b#anotherhashishere"]), + expect: None, + imports_field: imports_field(json!({ + "#a?q=a#hashishere": "b#anotherhashishere" + })), + request: "#a?q=a#hashishere", + condition_names: vec![], + }, + TestCase { + name: "Direct and conditional mapping #1", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "require": "./require.js" + }, + { + "import": "./import.mjs" + } + ] + })), + request: "#a", + condition_names: vec![], + }, + TestCase { + name: "Direct and conditional mapping #2", + expect: Some(vec!["./import.mjs"]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "require": "./require.js" + }, + { + "import": "./import.mjs" + } + ] + })), + request: "#a", + condition_names: vec!["import"], + }, + TestCase { + name: "Direct and conditional mapping #3", + expect: Some(vec!["./require.js"]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "require": "./require.js" + }, + { + "import": "./import.mjs" + } + ] + })), + request: "#a", + condition_names: vec!["import", "require"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "Direct and conditional mapping #3", + expect: Some(vec!["./import.mjs"]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "import": "./import.mjs" + } + ] + })), + request: "#a", + condition_names: vec!["import", "require"], + }, + TestCase { + name: "Direct and conditional mapping #4", + expect: Some(vec!["./require.js"]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "require": [ + "./require.js" + ] + }, + { + "import": [ + "./import.mjs", + "#b/import.js" + ] + } + ] + })), + request: "#a", + condition_names: vec!["import", "require"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "Direct and conditional mapping #4", + expect: Some(vec!["./import.mjs"]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "import": [ + "./import.mjs", + "#b/import.js" + ] + } + ] + })), + request: "#a", + condition_names: vec!["import", "require"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "Direct and conditional mapping #4", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a": [ + { + "browser": "./browser.js" + }, + { + "import": [ + "#b/import.js" + ] + } + ] + })), + request: "#a", + condition_names: vec!["import", "require"], + }, + TestCase { + name: "mapping to a folder root #1", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#timezones": "./data/timezones/" + })), + request: "#timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "mapping to a folder root #2", + expect: None, + imports_field: imports_field(json!({ + "#timezones/": "./data/timezones" + })), + request: "#timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "mapping to a folder root #3", + expect: Some(vec!["./data/timezones/pdt/index.mjs"]), + imports_field: imports_field(json!({ + "#timezones/pdt/": "./data/timezones/pdt/" + })), + request: "#timezones/pdt/index.mjs", + condition_names: vec![], + }, + TestCase { + name: "mapping to a folder root #4", + expect: Some(vec!["./timezones/pdt.mjs"]), + imports_field: imports_field(json!({ + "#a/": "./timezones/" + })), + request: "#a/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "mapping to a folder root #5", + expect: Some(vec!["./timezones/pdt.mjs"]), + imports_field: imports_field(json!({ + "#a/": "./" + })), + request: "#a/timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "mapping to a folder root #6", + expect: None, + imports_field: imports_field(json!({ + "#a/": "." + })), + request: "#a/timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "mapping to a folder root #7", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a": "./" + })), + request: "#a/timezones/pdt.mjs", + condition_names: vec![], + }, + TestCase { + name: "the longest matching path prefix is prioritized #1", + expect: Some(vec!["./lib/index.mjs"]), + imports_field: imports_field(json!({ + "#a/": "./", + "#a/dist/": "./lib/" + })), + request: "#a/dist/index.mjs", + condition_names: vec![], + }, + TestCase { + name: "the longest matching path prefix is prioritized #2", + expect: Some(vec!["./dist/utils/index.js"]), + imports_field: imports_field(json!({ + "#a/dist/utils/": "./dist/utils/", + "#a/dist/": "./lib/" + })), + request: "#a/dist/utils/index.js", + condition_names: vec![], + }, + TestCase { + name: "the longest matching path prefix is prioritized #3", + expect: Some(vec!["./dist/utils/index.js"]), + imports_field: imports_field(json!({ + "#a/dist/utils/index.js": "./dist/utils/index.js", + "#a/dist/utils/": "./dist/utils/index.mjs", + "#a/dist/": "./lib/" + })), + request: "#a/dist/utils/index.js", + condition_names: vec![], + }, + TestCase { + name: "the longest matching path prefix is prioritized #4", + expect: Some(vec!["./lib/index.mjs"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": "./browser/" + }, + "#a/dist/": "./lib/" + })), + request: "#a/dist/index.mjs", + condition_names: vec!["browser"], + }, + TestCase { + name: "conditional mapping folder #1", + // This behaves differently from enhanced_resolve, because `lodash/` is an an InvalidPackageConfig + // expect: Some(vec!["lodash/index.js"]), + expect: Some(vec!["./utils/index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": [ + "lodash/", + "./utils/" + ], + "node": [ + "./utils-node/" + ] + } + })), + request: "#a/index.js", + condition_names: vec!["browser"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "conditional mapping folder #1", + expect: Some(vec!["./utils/index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": [ + "./utils/" + ], + "node": [ + "./utils-node/" + ] + } + })), + request: "#a/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "conditional mapping folder #2", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "webpack": "./wpk/", + "browser": [ + "lodash/", + "./utils/" + ], + "node": [ + "./node/" + ] + } + })), + request: "#a/index.mjs", + condition_names: vec![], + }, + TestCase { + name: "conditional mapping folder #3", + expect: Some(vec!["./wpk/index.mjs"]), + imports_field: imports_field(json!({ + "#a/": { + "webpack": "./wpk/", + "browser": [ + "lodash/", + "./utils/" + ], + "node": [ + "./utils/" + ] + } + })), + request: "#a/index.mjs", + condition_names: vec!["browser", "webpack"], + }, + TestCase { + name: "incorrect exports field #1", + // We throw `PackageImportNotDefined` + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "/utils/": "./a/" + })), + request: "#a/index.mjs", + condition_names: vec![], + }, + TestCase { + name: "incorrect exports field #2", + // We throw `PackageImportNotDefined` + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "/utils/": { + "browser": "./a/", + "default": "./b/" + } + })), + request: "#a/index.mjs", + condition_names: vec!["browser"], + }, + TestCase { + name: "incorrect exports field #3", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/index": "./a/index.js" + })), + request: "#a/index.mjs", + condition_names: vec![], + }, + TestCase { + name: "incorrect exports field #4", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/index.mjs": "./a/index.js" + })), + request: "#a/index", + condition_names: vec![], + }, + TestCase { + name: "incorrect exports field #5", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/index": { + "browser": "./a/index.js", + "default": "./b/index.js" + } + })), + request: "#a/index.mjs", + condition_names: vec!["browser"], + }, + TestCase { + name: "incorrect exports field #6", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/index.mjs": { + "browser": "./a/index.js", + "default": "./b/index.js" + } + })), + request: "#a/index", + condition_names: vec!["browser"], + }, + TestCase { + name: "incorrect request #1", + // We don't throw in `package_imports_exports_resolve` + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": "./a/" + })), + request: "/utils/index.mjs", + condition_names: vec![], + }, + TestCase { + name: "incorrect request #2", + // We don't throw in `package_imports_exports_resolve` + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": "./a/", + "default": "./b/" + } + })), + request: "./utils/index.mjs", + condition_names: vec!["browser"], + }, + TestCase { + name: "incorrect request #3", + // We don't throw in `package_imports_exports_resolve`, it's thrown in `package_imports_resolve` + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": "./a/", + "default": "./b/" + } + })), + request: "#", + condition_names: vec!["browser"], + }, + TestCase { + name: "incorrect request #4", + // We don't throw in `package_imports_exports_resolve`, it's thrown in `package_imports_resolve` + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": "./a/", + "default": "./b/" + } + })), + request: "#/", + condition_names: vec!["browser"], + }, + TestCase { + name: "incorrect request #5", + // expect: None, + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": "./a/", + "default": "./b/" + } + })), + request: "#a/", + condition_names: vec!["browser"], + }, + TestCase { + name: "backtracking package base #1", + // expect: Some(vec!["./dist/index"]), + expect: Some(vec!["dist/index"]), + imports_field: imports_field(json!({ + "#a/../../utils/": "./dist/" + })), + request: "#a/../../utils/index", + condition_names: vec![], + }, + TestCase { + name: "backtracking package base #2", + // We throw InvalidPackageTarget + // expect: Some(vec!["./dist/../../utils/index"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": "./dist/" + })), + request: "#a/../../utils/index", + condition_names: vec![], + }, + TestCase { + name: "backtracking package base #3", + // We throw InvalidPackageTarget + // expect: Some(vec!["../src/index"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": "../src/" + })), + request: "#a/index", + condition_names: vec![], + }, + TestCase { + name: "backtracking package base #4", + // We throw InvalidPackageTarget + // expect: Some(vec!["./utils/../../../index"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": { + "browser": "./utils/../../../" + } + })), + request: "#a/index", + condition_names: vec!["browser"], + }, + TestCase { + name: "nested node_modules path #1", + // expect: Some(vec!["moment/node_modules/lodash/dist/index.js"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": { + "browser": "moment/node_modules/" + } + })), + request: "#a/lodash/dist/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "nested node_modules path #2", + // We throw InvalidPackageTarget + // expect: Some(vec!["../node_modules/lodash/dist/index.js"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": "../node_modules/" + })), + request: "#a/lodash/dist/index.js", + condition_names: vec![], + }, + TestCase { + name: "nested mapping #1", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": "./", + "default": { + "node": "./node/" + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["browser"], + }, + TestCase { + name: "nested mapping #2", + expect: Some(vec!["./index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./", + "./node/" + ], + "default": { + "node": "./node/" + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["browser", "webpack"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "nested mapping #2", + expect: Some(vec!["./node/index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./node/" + ], + "default": { + "node": "./node/" + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["browser", "webpack"], + }, + TestCase { + name: "nested mapping #3", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./", + "./node/" + ], + "default": { + "node": "./node/" + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["webpack"], + }, + TestCase { + name: "nested mapping #4", + // We throw NotFound + // expect: Some(vec!["moment/node/index.js"]), + expect: None, + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./", + "./node/" + ], + "default": { + "node": "moment/node/" + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["node", "browser"], + }, + TestCase { + name: "nested mapping #5", + expect: Some(vec![]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./", + "./node/" + ], + "default": { + "node": { + "webpack": [ + "./wpck/" + ] + } + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["browser", "node"], + }, + TestCase { + name: "nested mapping #6", + expect: Some(vec!["./index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./", + "./node/" + ], + "default": { + "node": { + "webpack": [ + "./wpck/" + ] + } + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["browser", "node", "webpack"], + }, + // Duplicated due to not supporting returning an array + TestCase { + name: "nested mapping #6", + expect: Some(vec!["./node/index.js"]), + imports_field: imports_field(json!({ + "#a/": { + "browser": { + "webpack": [ + "./node/" + ], + "default": { + "node": { + "webpack": [ + "./wpck/" + ] + } + } + } + } + })), + request: "#a/index.js", + condition_names: vec!["browser", "node", "webpack"], + }, + TestCase { + name: "nested mapping #7", + expect: Some(vec!["./y.js"]), + imports_field: imports_field(json!({ + "#a": { + "abc": { + "def": "./x.js" + }, + "ghi": "./y.js" + } + })), + request: "#a", + condition_names: vec!["abc", "ghi"], + }, + TestCase { + name: "nested mapping #8", + // We throw PackageImportNotDefined + // expect: Some(vec![]), + expect: None, + imports_field: imports_field(json!({ + "#a": { + "abc": { + "def": "./x.js", + "default": [] + }, + "ghi": "./y.js" + } + })), + request: "#a", + condition_names: vec!["abc", "ghi"], + }, + ]; + + for case in test_cases { + let resolved = Resolver::default() + .package_imports_exports_resolve( + case.request, + &case.imports_field, + Path::new(""), + true, + &case.condition_names.iter().map(ToString::to_string).collect::>(), + ) + .map(|p| p.map(|p| p.to_path_buf())); + if let Some(expect) = case.expect { + if expect.is_empty() { + assert!(matches!(resolved, Ok(None)), "{} {:?}", &case.name, &resolved); + } else { + for expect in expect { + assert_eq!(resolved, Ok(Some(Path::new(expect).normalize())), "{}", &case.name); + } + } + } else { + assert!(resolved.is_err(), "{} {resolved:?}", &case.name); + } + } +} diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs index a95abe016..f0e2516c6 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs @@ -4,6 +4,7 @@ mod exports_field; mod extension_alias; mod extensions; mod fallback; +mod imports_field; mod incorrect_description_file; mod resolve; mod roots;