diff --git a/src/lib.rs b/src/lib.rs index d8d49a2e..6d2a14b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,7 +60,6 @@ mod tsconfig; #[cfg(test)] mod tests; -use rustc_hash::FxHashSet; use std::{ borrow::Cow, cmp::Ordering, @@ -69,6 +68,9 @@ use std::{ path::{Component, Path, PathBuf}, sync::Arc, }; + +use rustc_hash::FxHashSet; +use serde_json::Value as JSONValue; use typescript_tsconfig_json::ExtendsField; pub use crate::{ @@ -86,11 +88,11 @@ use crate::{ cache::{Cache, CachedPath}, context::ResolveContext as Ctx, file_system::FileSystemOs, + package_json::ImportExportMap, path::{PathUtil, SLASH_START}, specifier::Specifier, tsconfig::{ProjectReference, TsConfig}, }; -use nodejs_package_json::{ImportExportField, ImportExportKey, ImportExportMap}; type ResolveResult = Result, ResolveError>; @@ -749,16 +751,13 @@ impl ResolverGeneric { }; // 3. Parse DIR/NAME/package.json, and look for "exports" field. // 4. If "exports" is null or undefined, return. - if package_json.exports.is_empty() { - return Ok(None); - }; // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH, // `package.json` "exports", ["node", "require"]) defined in the ESM resolver. // Note: The subpath is not prepended with a dot on purpose - for exports in &package_json.exports { + for exports in package_json.exports_fields(&self.options.exports_fields) { if let Some(path) = self.package_exports_resolve( cached_path.path(), - subpath, + &format!(".{subpath}"), exports, &self.options.condition_names, ctx, @@ -784,30 +783,28 @@ impl ResolverGeneric { return Ok(None); }; // 3. If the SCOPE/package.json "exports" is null or undefined, return. - if !package_json.exports.is_empty() { - // 4. If the SCOPE/package.json "name" is not the first segment of X, return. - if let Some(subpath) = package_json - .name - .as_ref() - .and_then(|package_name| Self::strip_package_name(specifier, package_name)) - { - // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE), - // "." + X.slice("name".length), `package.json` "exports", ["node", "require"]) - // defined in the ESM resolver. - let package_url = package_json.directory(); - // Note: The subpath is not prepended with a dot on purpose - // because `package_exports_resolve` matches subpath without the leading dot. - for exports in &package_json.exports { - if let Some(cached_path) = self.package_exports_resolve( - package_url, - subpath, - exports, - &self.options.condition_names, - ctx, - )? { - // 6. RESOLVE_ESM_MATCH(MATCH) - return self.resolve_esm_match(specifier, &cached_path, &package_json, ctx); - } + // 4. If the SCOPE/package.json "name" is not the first segment of X, return. + if let Some(subpath) = package_json + .name + .as_ref() + .and_then(|package_name| Self::strip_package_name(specifier, package_name)) + { + // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE), + // "." + X.slice("name".length), `package.json` "exports", ["node", "require"]) + // defined in the ESM resolver. + let package_url = package_json.directory(); + // Note: The subpath is not prepended with a dot on purpose + // because `package_exports_resolve` matches subpath without the leading dot. + for exports in package_json.exports_fields(&self.options.exports_fields) { + if let Some(cached_path) = self.package_exports_resolve( + package_url, + &format!(".{subpath}"), + exports, + &self.options.condition_names, + ctx, + )? { + // 6. RESOLVE_ESM_MATCH(MATCH) + return self.resolve_esm_match(specifier, &cached_path, &package_json, ctx); } } } @@ -1143,18 +1140,16 @@ impl ResolverGeneric { cached_path.package_json(&self.cache.fs, &self.options, ctx)? { // 5. If pjson is not null and pjson.exports is not null or undefined, then - if !package_json.exports.is_empty() { - // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions). - for exports in &package_json.exports { - if let Some(path) = self.package_exports_resolve( - cached_path.path(), - subpath, - exports, - &self.options.condition_names, - ctx, - )? { - return Ok(Some(path)); - } + // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions). + for exports in package_json.exports_fields(&self.options.exports_fields) { + if let Some(path) = self.package_exports_resolve( + cached_path.path(), + &format!(".{subpath}"), + exports, + &self.options.condition_names, + ctx, + )? { + return Ok(Some(path)); } } // 6. Otherwise, if packageSubpath is equal to ".", then @@ -1187,18 +1182,17 @@ impl ResolverGeneric { &self, package_url: &Path, subpath: &str, - exports: &ImportExportField, + exports: &JSONValue, conditions: &[String], ctx: &mut Ctx, ) -> ResolveResult { // 1. If exports is an Object with both a key starting with "." and a key not starting with ".", throw an Invalid Package Configuration error. - if let ImportExportField::Map(map) = exports { + if let JSONValue::Object(map) = exports { let mut has_dot = false; let mut without_dot = false; for key in map.keys() { - has_dot = - has_dot || matches!(key, ImportExportKey::Main | ImportExportKey::Pattern(_)); - without_dot = without_dot || matches!(key, ImportExportKey::CustomCondition(_)); + has_dot = has_dot || key.starts_with(|s| s == '.' || s == '#'); + without_dot = without_dot || !key.starts_with(|s| s == '.' || s == '#'); if has_dot && without_dot { return Err(ResolveError::InvalidPackageConfig( package_url.join("package.json"), @@ -1208,7 +1202,7 @@ impl ResolverGeneric { } // 2. If subpath is equal to ".", then // Note: subpath is not prepended with a dot when passed in. - if subpath.is_empty() { + if subpath == "." { // enhanced-resolve appends query and fragment when resolving exports field // https://github.com/webpack/enhanced-resolve/blob/a998c7d218b7a9ec2461fc4fddd1ad5dd7687485/lib/ExportsFieldPlugin.js#L57-L62 // This is only need when querying the main export, otherwise ctx is passed through. @@ -1216,24 +1210,23 @@ impl ResolverGeneric { let query = ctx.query.clone().unwrap_or_default(); let fragment = ctx.fragment.clone().unwrap_or_default(); return Err(ResolveError::PackagePathNotExported( - format!("./{subpath}{query}{fragment}"), + format!("./{}{query}{fragment}", subpath.trim_start_matches('.')), package_url.join("package.json"), )); } // 1. Let mainExport be undefined. let main_export = match exports { - ImportExportField::None => None, // 2. If exports is a String or Array, or an Object containing no keys starting with ".", then - ImportExportField::String(_) | ImportExportField::Array(_) => { + JSONValue::String(_) | JSONValue::Array(_) => { // 1. Set mainExport to exports. Some(exports) } // 3. Otherwise if exports is an Object containing a "." property, then - ImportExportField::Map(map) => { + JSONValue::Object(map) => { // 1. Set mainExport to exports["."]. - map.get(&ImportExportKey::Main).map_or_else( + map.get(".").map_or_else( || { - if map.keys().any(|key| matches!(key, ImportExportKey::Pattern(_))) { + if map.keys().any(|key| key.starts_with("./") || key.starts_with('#')) { None } else { Some(exports) @@ -1242,6 +1235,7 @@ impl ResolverGeneric { Some, ) } + _ => None, }; // 4. If mainExport is not undefined, then if let Some(main_export) = main_export { @@ -1262,7 +1256,7 @@ impl ResolverGeneric { } } // 3. Otherwise, if exports is an Object and all keys of exports start with ".", then - if let ImportExportField::Map(exports) = exports { + if let JSONValue::Object(exports) = exports { // 1. Let matchKey be the string "./" concatenated with subpath. // Note: `package_imports_exports_resolve` does not require the leading dot. let match_key = &subpath; @@ -1281,7 +1275,7 @@ impl ResolverGeneric { } // 4. Throw a Package Path Not Exported error. Err(ResolveError::PackagePathNotExported( - format!(".{subpath}"), + subpath.to_string(), package_url.join("package.json"), )) } @@ -1346,7 +1340,7 @@ impl ResolverGeneric { // 1. If matchKey is a key of matchObj and does not contain "*", then if !match_key.contains('*') { // 1. Let target be the value of matchObj[matchKey]. - if let Some(target) = match_obj.get(&ImportExportKey::Pattern(match_key.to_string())) { + if let Some(target) = match_obj.get(match_key) { // 2. Return the result of PACKAGE_TARGET_RESOLVE(packageURL, target, null, isImports, conditions). return self.package_target_resolve( package_url, @@ -1366,7 +1360,7 @@ impl ResolverGeneric { // 2. Let expansionKeys be the list of keys of matchObj containing only a single "*", sorted by the sorting function PATTERN_KEY_COMPARE which orders in descending order of specificity. // 3. For each key expansionKey in expansionKeys, do for (expansion_key, target) in match_obj { - if let ImportExportKey::Pattern(expansion_key) = expansion_key { + if expansion_key.starts_with("./") || expansion_key.starts_with('#') { // 1. Let patternBase be the substring of expansionKey up to but excluding the first "*" character. if let Some((pattern_base, pattern_trailer)) = expansion_key.split_once('*') { // 2. If matchKey starts with but is not equal to patternBase, then @@ -1419,7 +1413,7 @@ impl ResolverGeneric { &self, package_url: &Path, target_key: &str, - target: &ImportExportField, + target: &JSONValue, pattern_match: Option<&str>, is_imports: bool, conditions: &[String], @@ -1452,9 +1446,8 @@ impl ResolverGeneric { } match target { - ImportExportField::None => {} // 1. If target is a String, then - ImportExportField::String(target) => { + JSONValue::String(target) => { // 1. If target does not start with "./", then if !target.starts_with("./") { // 1. If isImports is false, or if target starts with "../" or "/", or if target is a valid URL, then @@ -1495,14 +1488,14 @@ impl ResolverGeneric { return Ok(Some(value)); } // 2. Otherwise, if target is a non-null Object, then - ImportExportField::Map(target) => { + JSONValue::Object(target) => { // 1. If exports contains any index property keys, as defined in ECMA-262 6.1.7 Array Index, throw an Invalid Package Configuration error. // 2. For each property p of target, in object insertion order as, for (i, (key, target_value)) in target.iter().enumerate() { // https://nodejs.org/api/packages.html#conditional-exports // "default" - the generic fallback that always matches. Can be a CommonJS or ES module file. This condition should always come last. // Note: node.js does not throw this but enhanced-resolve does. - let is_default = matches!(key, ImportExportKey::CustomCondition(condition) if condition == "default"); + let is_default = key == "default"; if i < target.len() - 1 && is_default { return Err(ResolveError::InvalidPackageConfigDefault( package_url.join("package.json"), @@ -1510,9 +1503,7 @@ impl ResolverGeneric { } // 1. If p equals "default" or conditions contains an entry for p, then - if is_default - || matches!(key, ImportExportKey::CustomCondition(condition) if conditions.contains(condition)) - { + if is_default || conditions.contains(key) { // 1. Let targetValue be the value of the p property in target. // 2. Let resolved be the result of PACKAGE_TARGET_RESOLVE( packageURL, targetValue, patternMatch, isImports, conditions). let resolved = self.package_target_resolve( @@ -1535,12 +1526,12 @@ impl ResolverGeneric { return Ok(None); } // 3. Otherwise, if target is an Array, then - ImportExportField::Array(targets) => { + JSONValue::Array(targets) => { // 1. If _target.length is zero, return null. if targets.is_empty() { // Note: return PackagePathNotExported has the same effect as return because there are no matches. return Err(ResolveError::PackagePathNotExported( - format!(".{}", pattern_match.unwrap_or(".")), + pattern_match.unwrap_or(".").to_string(), package_url.join("package.json"), )); } @@ -1570,6 +1561,7 @@ impl ResolverGeneric { // 3. Return or throw the last fallback resolution null return or error. // Note: see `resolved.is_err() && i == targets.len()` } + _ => {} } // 4. Otherwise, if target is null, return null. Ok(None) diff --git a/src/package_json.rs b/src/package_json.rs index d4e075b9..e72eaeb8 100644 --- a/src/package_json.rs +++ b/src/package_json.rs @@ -3,12 +3,14 @@ //! Code related to export field are copied from [Parcel's resolver](https://github.com/parcel-bundler/parcel/blob/v2/packages/utils/node-resolver-rs/src/package_json.rs) use std::path::{Path, PathBuf}; -use nodejs_package_json::{BrowserField, ImportExportField, ImportExportMap}; +use nodejs_package_json::BrowserField; use serde::Deserialize; -use serde_json::Value; +use serde_json::Value as JSONValue; use crate::{path::PathUtil, ResolveError, ResolveOptions}; +pub type ImportExportMap = serde_json::Map; + /// Deserialized package.json #[derive(Debug, Default)] pub struct PackageJson { @@ -18,7 +20,7 @@ pub struct PackageJson { /// Realpath to `package.json`. Contains the `package.json` filename. pub realpath: PathBuf, - raw_json: std::sync::Arc, + raw_json: std::sync::Arc, /// The "name" field defines your package's name. /// The "name" field can be used in addition to the "exports" field to self-reference a package using its name. @@ -26,11 +28,6 @@ pub struct PackageJson { /// pub name: Option, - /// The "exports" field allows defining the entry points of a package when imported by name loaded either via a node_modules lookup or a self-reference to its own name. - /// - /// - pub exports: Vec, - /// 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. /// /// @@ -52,10 +49,9 @@ impl PackageJson { json: &str, options: &ResolveOptions, ) -> Result { - let mut raw_json: Value = serde_json::from_str(json)?; + let mut raw_json: JSONValue = serde_json::from_str(json)?; let mut package_json = Self::default(); - package_json.exports.reserve_exact(options.exports_fields.len()); package_json.browser_fields.reserve_exact(options.alias_fields.len()); if let Some(json_object) = raw_json.as_object_mut() { @@ -76,11 +72,8 @@ impl PackageJson { json_object.get("name").and_then(|field| field.as_str()).map(ToString::to_string); // Add imports. - package_json.imports = json_object - .get("imports") - .map(ImportExportMap::deserialize) - .transpose()? - .map(Box::new); + package_json.imports = + json_object.get("imports").and_then(|v| v.as_object()).cloned().map(Box::new); // Dynamically create `browser_fields`. let dir = path.parent().unwrap(); @@ -107,14 +100,6 @@ impl PackageJson { package_json.browser_fields.push(browser_field); } } - - // Dynamically create `exports`. - for object_path in &options.exports_fields { - if let Some(exports) = Self::get_value_by_path(json_object, object_path) { - let exports = ImportExportField::deserialize(exports)?; - package_json.exports.push(exports); - } - } } package_json.path = path; @@ -124,9 +109,9 @@ impl PackageJson { } fn get_value_by_path<'a>( - fields: &'a serde_json::Map, + fields: &'a serde_json::Map, path: &[String], - ) -> Option<&'a serde_json::Value> { + ) -> Option<&'a JSONValue> { if path.is_empty() { return None; } @@ -153,7 +138,7 @@ impl PackageJson { /// They are: `description`, `keywords`, `scripts`, /// `dependencies` and `devDependencies`, `peerDependencies`, `optionalDependencies`. #[cfg(feature = "package_json_raw_json_api")] - pub fn raw_json(&self) -> &std::sync::Arc { + pub fn raw_json(&self) -> &std::sync::Arc { &self.raw_json } @@ -184,6 +169,20 @@ impl PackageJson { .filter_map(|value| value.as_str()) } + /// The "exports" field allows defining the entry points of a package when imported by name loaded either via a node_modules lookup or a self-reference to its own name. + /// + /// + pub(crate) fn exports_fields<'a>( + &'a self, + exports_fields: &'a [Vec], + ) -> impl Iterator + '_ { + exports_fields.iter().filter_map(|object_path| { + self.raw_json + .as_object() + .and_then(|json_object| Self::get_value_by_path(json_object, object_path)) + }) + } + /// Resolve the request string for this package.json by looking at the `browser` field. /// /// # Errors @@ -210,13 +209,10 @@ impl PackageJson { Ok(None) } - fn alias_value<'a>( - key: &Path, - value: &'a serde_json::Value, - ) -> Result, ResolveError> { + fn alias_value<'a>(key: &Path, value: &'a JSONValue) -> Result, ResolveError> { match value { - serde_json::Value::String(value) => Ok(Some(value.as_str())), - serde_json::Value::Bool(b) if !b => Err(ResolveError::Ignored(key.to_path_buf())), + JSONValue::String(value) => Ok(Some(value.as_str())), + JSONValue::Bool(b) if !b => Err(ResolveError::Ignored(key.to_path_buf())), _ => Ok(None), } } diff --git a/src/tests/exports_field.rs b/src/tests/exports_field.rs index 7e4d081d..3fa2fb75 100644 --- a/src/tests/exports_field.rs +++ b/src/tests/exports_field.rs @@ -2,12 +2,12 @@ //! //! The huge exports field test cases are at the bottom of this file. -use crate::{Ctx, ImportExportField, PathUtil, ResolveError, ResolveOptions, Resolver}; +use crate::{Ctx, PathUtil, ResolveError, ResolveOptions, Resolver}; use serde_json::json; use std::path::Path; #[test] -fn test() { +fn test_simple() { let f = super::fixture().join("exports-field"); let f2 = super::fixture().join("exports-field2"); let f4 = super::fixture().join("exports-field-error"); @@ -64,8 +64,8 @@ fn test() { ("resolver should respect query parameters #2. Direct matching", f2.clone(), "exports-field?foo", ResolveError::PackagePathNotExported("./?foo".into(), p2.clone())), ("resolver should respect fragment parameters #2. Direct matching", f2, "exports-field#foo", ResolveError::PackagePathNotExported("./#foo".into(), p2.clone())), ("relative path should not work with exports field", f.clone(), "./node_modules/exports-field/dist/main.js", ResolveError::NotFound("./node_modules/exports-field/dist/main.js".into())), - ("backtracking should not work for request", f.clone(), "exports-field/dist/../../../a.js", ResolveError::InvalidPackageTarget("./lib/../../../a.js".to_string(), "/dist/".to_string(), p.clone())), - ("backtracking should not work for exports field target", f.clone(), "exports-field/dist/a.js", ResolveError::InvalidPackageTarget("./../../a.js".to_string(), "/dist/a.js".to_string(), p.clone())), + ("backtracking should not work for request", f.clone(), "exports-field/dist/../../../a.js", ResolveError::InvalidPackageTarget("./lib/../../../a.js".to_string(), "./dist/".to_string(), p.clone())), + ("backtracking should not work for exports field target", f.clone(), "exports-field/dist/a.js", ResolveError::InvalidPackageTarget("./../../a.js".to_string(), "./dist/a.js".to_string(), p.clone())), ("not exported error", f.clone(), "exports-field/anything/else", ResolveError::PackagePathNotExported("./anything/else".to_string(), p.clone())), ("request ending with slash #1", f.clone(), "exports-field/", ResolveError::PackagePathNotExported("./".to_string(), p.clone())), ("request ending with slash #2", f.clone(), "exports-field/dist/", ResolveError::PackagePathNotExported("./dist/".to_string(), p.clone())), @@ -182,6 +182,27 @@ fn field_name_path() { assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/src/index.js"))); } +#[test] +fn shared_resolvers() { + let f3 = super::fixture().join("exports-field3"); + + let resolver1 = Resolver::new(ResolveOptions { + exports_fields: vec![vec!["exportsField".into(), "exports".into()]], + extensions: vec![".js".into()], + ..ResolveOptions::default() + }); + let resolved_path = resolver1.resolve(&f3, "exports-field").map(|r| r.full_path()); + assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/main.js"))); + + let resolver2 = resolver1.clone_with_options(ResolveOptions { + exports_fields: vec![vec!["ex".into()]], + extensions: vec![".js".into()], + ..ResolveOptions::default() + }); + let resolved_path = resolver2.resolve(&f3, "exports-field").map(|r| r.full_path()); + assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/index"))); +} + #[test] fn extension_alias_1_2() { let f = super::fixture().join("exports-field-and-extension-alias"); @@ -276,15 +297,14 @@ fn extension_alias_throw_error() { struct TestCase { name: &'static str, expect: Option>, - exports_field: ImportExportField, + exports_field: serde_json::Value, request: &'static str, condition_names: Vec<&'static str>, } #[allow(clippy::needless_pass_by_value)] -fn exports_field(value: serde_json::Value) -> ImportExportField { - let s = serde_json::to_string(&value).unwrap(); - serde_json::from_str(&s).unwrap() +fn exports_field(value: serde_json::Value) -> serde_json::Value { + value } #[test] @@ -2494,7 +2514,7 @@ fn test_cases() { let resolved = Resolver::default() .package_exports_resolve( Path::new(""), - case.request.trim_start_matches('.'), + case.request, &case.exports_field, &case.condition_names.iter().map(ToString::to_string).collect::>(), &mut Ctx::default(), diff --git a/src/tests/imports_field.rs b/src/tests/imports_field.rs index 99cb1b05..4fb1e250 100644 --- a/src/tests/imports_field.rs +++ b/src/tests/imports_field.rs @@ -8,7 +8,7 @@ use crate::{Ctx, ImportExportMap, PathUtil, ResolveError, ResolveOptions, Resolv use std::path::Path; #[test] -fn test() { +fn test_simple() { let f = super::fixture().join("imports-field"); let f2 = super::fixture().join("imports-exports-wildcard/node_modules/m/");