Repository: oxc-project/oxc-resolver
Pull request #138 (2024-04-23 09:43:36)

feat: add `imports_fields` option
relates #135

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/oxc-project/oxc-resolver/branches/imports-fields)

### Merging #138 will **not alter performance**

<sub>Comparing <code>imports-fields</code> (c475daa) with <code>main</code> (675f5e3)</sub>



### Summary

`✅ 2` untouched benchmarks





codecov[bot]: ## [Codecov](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) Report
Attention: Patch coverage is `85.36585%` with `6 lines` in your changes are missing coverage. Please review.
> Project coverage is 95.08%. Comparing base [(`675f5e3`)](https://app.codecov.io/gh/oxc-project/oxc-resolver/commit/675f5e3478044678965b4fa85ad4edc6c675dbcf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) to head [(`408cb0d`)](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project).

| [Files](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) | Patch % | Lines |
|---|---|---|
| [src/lib.rs](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?src=pr&el=tree&filepath=src%2Flib.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project#diff-c3JjL2xpYi5ycw==) | 79.16% | [5 Missing :warning: ](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) |
| [src/options.rs](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?src=pr&el=tree&filepath=src%2Foptions.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project#diff-c3JjL29wdGlvbnMucnM=) | 83.33% | [1 Missing :warning: ](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #138      +/-   ##
==========================================
- Coverage   95.08%   95.08%   -0.01%     
==========================================
  Files          11       11              
  Lines        2238     2257      +19     
==========================================
+ Hits         2128     2146      +18     
- Misses        110      111       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/138?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project).


Patch:
diff --git a/napi/src/lib.rs b/napi/src/lib.rs
index 62e09a5..238f99c 100644
--- a/napi/src/lib.rs
+++ b/napi/src/lib.rs
@@ -109,6 +109,10 @@ impl ResolverFactory {
                 .exports_fields
                 .map(|o| o.into_iter().map(|x| StrOrStrList(x).into()).collect::<Vec<_>>())
                 .unwrap_or(default.exports_fields),
+            imports_fields: op
+                .imports_fields
+                .map(|o| o.into_iter().map(|x| StrOrStrList(x).into()).collect::<Vec<_>>())
+                .unwrap_or(default.imports_fields),
             extension_alias: op
                 .extension_alias
                 .map(|extension_alias| extension_alias.into_iter().collect::<Vec<_>>())
diff --git a/napi/src/options.rs b/napi/src/options.rs
index f1ecb14..ea35672 100644
--- a/napi/src/options.rs
+++ b/napi/src/options.rs
@@ -62,6 +62,15 @@ pub struct NapiResolveOptions {
     #[napi(ts_type = "(string | string[])[]")]
     pub exports_fields: Option<Vec<StrOrStrListType>>,
 
+    /// Fields from `package.json` which are used to provide the internal requests of a package
+    /// (requests starting with # are considered internal).
+    ///
+    /// Can be a path to a JSON object such as `["path", "to", "imports"]`.
+    ///
+    /// Default `[["imports"]]`.
+    #[napi(ts_type = "(string | string[])[]")]
+    pub imports_fields: Option<Vec<StrOrStrListType>>,
+
     /// An object which maps extension to extension aliases.
     ///
     /// Default `{}`
diff --git a/src/lib.rs b/src/lib.rs
index 6d2a14b..1b8ac85 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -474,15 +474,12 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
             return Ok(None);
         };
         // 3. If the SCOPE/package.json "imports" is null or undefined, return.
-        if package_json.imports.is_none()
-            || package_json.imports.as_ref().is_some_and(|imports| imports.is_empty())
-        {
-            return Ok(None);
-        }
         // 4. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE), ["node", "require"]) defined in the ESM resolver.
-        let path = self.package_imports_resolve(specifier, &package_json, ctx)?;
-        // 5. RESOLVE_ESM_MATCH(MATCH).
-        self.resolve_esm_match(specifier, &path, &package_json, ctx)
+        if let Some(path) = self.package_imports_resolve(specifier, &package_json, ctx)? {
+            // 5. RESOLVE_ESM_MATCH(MATCH).
+            return self.resolve_esm_match(specifier, &path, &package_json, ctx);
+        }
+        Ok(None)
     }
 
     fn load_as_file(&self, cached_path: &CachedPath, ctx: &mut Ctx) -> ResolveResult {
@@ -1286,17 +1283,11 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         specifier: &str,
         package_json: &PackageJson,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<Option<CachedPath>, ResolveError> {
         // 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(),
-                package_json.path.clone(),
-            ));
-        }
+        //   2. If specifier is exactly equal to "#" or starts with "#/", then
+        //   1. Throw an Invalid Module Specifier error.
         // 3. Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL).
         // 4. If packageURL is not null, then
 
@@ -1304,7 +1295,18 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         // 2. If pjson.imports is a non-null Object, then
 
         // 1. Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE( specifier, pjson.imports, packageURL, true, conditions).
-        if let Some(imports) = &package_json.imports {
+        let mut has_imports = false;
+        for imports in package_json.imports_fields(&self.options.imports_fields) {
+            if !has_imports {
+                has_imports = true;
+                // TODO: fill in test case for this case
+                if specifier == "#" || specifier.starts_with("#/") {
+                    return Err(ResolveError::InvalidModuleSpecifier(
+                        specifier.to_string(),
+                        package_json.path.clone(),
+                    ));
+                }
+            }
             if let Some(path) = self.package_imports_exports_resolve(
                 specifier,
                 imports,
@@ -1314,12 +1316,19 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 ctx,
             )? {
                 // 2. If resolved is not null or undefined, return resolved.
-                return Ok(path);
+                return Ok(Some(path));
             }
         }
 
         // 5. Throw a Package Import Not Defined error.
-        Err(ResolveError::PackageImportNotDefined(specifier.to_string(), package_json.path.clone()))
+        if has_imports {
+            Err(ResolveError::PackageImportNotDefined(
+                specifier.to_string(),
+                package_json.path.clone(),
+            ))
+        } else {
+            Ok(None)
+        }
     }
 
     /// PACKAGE_IMPORTS_EXPORTS_RESOLVE(matchKey, matchObj, packageURL, isImports, conditions)
diff --git a/src/options.rs b/src/options.rs
index 24bed14..0f8360b 100644
--- a/src/options.rs
+++ b/src/options.rs
@@ -65,6 +65,14 @@ pub struct ResolveOptions {
     /// Default `[["exports"]]`.
     pub exports_fields: Vec<Vec<String>>,
 
+    /// Fields from `package.json` which are used to provide the internal requests of a package
+    /// (requests starting with # are considered internal).
+    ///
+    /// Can be a path to a JSON object such as `["path", "to", "imports"]`.
+    ///
+    /// Default `[["imports"]]`.
+    pub imports_fields: Vec<Vec<String>>,
+
     /// An object which maps extension to extension aliases.
     ///
     /// Default `{}`
@@ -446,6 +454,7 @@ impl Default for ResolveOptions {
             enforce_extension: EnforceExtension::Auto,
             extension_alias: vec![],
             exports_fields: vec![vec!["exports".into()]],
+            imports_fields: vec![vec!["imports".into()]],
             extensions: vec![".js".into(), ".json".into(), ".node".into()],
             fallback: vec![],
             fully_specified: false,
@@ -484,6 +493,9 @@ impl fmt::Display for ResolveOptions {
         if !self.exports_fields.is_empty() {
             write!(f, "exports_fields:{:?},", self.exports_fields)?;
         }
+        if !self.imports_fields.is_empty() {
+            write!(f, "imports_fields:{:?},", self.imports_fields)?;
+        }
         if !self.extension_alias.is_empty() {
             write!(f, "extension_alias:{:?},", self.extension_alias)?;
         }
@@ -566,6 +578,7 @@ mod test {
             enforce_extension: EnforceExtension::Enabled,
             extension_alias: vec![(".js".into(), vec![".ts".into()])],
             exports_fields: vec![vec!["exports".into()]],
+            imports_fields: vec![vec!["imports".into()]],
             fallback: vec![("fallback".into(), vec![AliasValue::Ignore])],
             fully_specified: true,
             resolve_to_context: true,
@@ -577,7 +590,7 @@ mod test {
             ..ResolveOptions::default()
         };
 
-        let expected = r#"tsconfig:TsconfigOptions { config_file: "tsconfig.json", references: Auto },alias:[("a", [Ignore])],alias_fields:[["browser"]],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,builtin_modules:true,"#;
+        let expected = r#"tsconfig:TsconfigOptions { config_file: "tsconfig.json", references: Auto },alias:[("a", [Ignore])],alias_fields:[["browser"]],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],imports_fields:[["imports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,builtin_modules:true,"#;
         assert_eq!(format!("{options}"), expected);
     }
 }
diff --git a/src/package_json.rs b/src/package_json.rs
index e72eaeb..6a748c6 100644
--- a/src/package_json.rs
+++ b/src/package_json.rs
@@ -28,11 +28,6 @@ pub struct PackageJson {
     /// <https://nodejs.org/api/packages.html#name>
     pub name: Option<String>,
 
-    /// 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.
-    ///
-    /// <https://nodejs.org/api/packages.html#subpath-imports>
-    pub imports: Option<Box<ImportExportMap>>,
-
     /// 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.
     /// Multiple values are configured by [ResolveOptions::alias_fields].
     ///
@@ -71,10 +66,6 @@ impl PackageJson {
             package_json.name =
                 json_object.get("name").and_then(|field| field.as_str()).map(ToString::to_string);
 
-            // Add imports.
-            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();
             for object_path in &options.alias_fields {
@@ -183,6 +174,21 @@ impl PackageJson {
         })
     }
 
+    /// 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.
+    ///
+    /// <https://nodejs.org/api/packages.html#subpath-imports>
+    pub(crate) fn imports_fields<'a>(
+        &'a self,
+        imports_fields: &'a [Vec<String>],
+    ) -> impl Iterator<Item = &'a ImportExportMap> + '_ {
+        imports_fields.iter().filter_map(|object_path| {
+            self.raw_json
+                .as_object()
+                .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
+                .and_then(|value| value.as_object())
+        })
+    }
+
     /// Resolve the request string for this package.json by looking at the `browser` field.
     ///
     /// # Errors


Test patch:
diff --git a/src/tests/imports_field.rs b/src/tests/imports_field.rs
index 4fb1e25..3bf81eb 100644
--- a/src/tests/imports_field.rs
+++ b/src/tests/imports_field.rs
@@ -50,6 +50,32 @@ fn test_simple() {
     }
 }
 
+#[test]
+fn shared_resolvers() {
+    let f = super::fixture().join("imports-field");
+
+    // field name #1
+    let resolver1 = Resolver::new(ResolveOptions {
+        extensions: vec![".js".into()],
+        main_files: vec!["index.js".into()],
+        imports_fields: vec![vec!["imports".into()]],
+        condition_names: vec!["webpack".into()],
+        ..ResolveOptions::default()
+    });
+
+    let resolved_path = resolver1.resolve(&f, "#imports-field").map(|r| r.full_path());
+    assert_eq!(resolved_path, Ok(f.join("b.js")));
+
+    // field name #2
+    let resolver2 = resolver1.clone_with_options(ResolveOptions {
+        imports_fields: vec![vec!["other".into(), "imports".into()]],
+        ..ResolveOptions::default()
+    });
+
+    let resolved_path = resolver2.resolve(&f, "#b").map(|r| r.full_path());
+    assert_eq!(resolved_path, Ok(f.join("a.js")));
+}
+
 // Small script for generating the test cases from enhanced_resolve
 // for (c of testCases) {
 //  console.log("TestCase {")