Repository: un-ts/eslint-plugin-import-x (ts)

Issue:
[Proposal] Support using any object satisfied `Resolver` type in `import-x/resolver` options
The resolver currently supports three reference methods:

- name -> eslint-import-resolver-name
- npm package name
- filesystem path

I propose to support the direct use of any object satisfied `Resolver` type, and I further propose configuration other than this should be deprecated in the future.

Advantage:

- Reduce complexity when trying to require resolvers
- Defining a custom resolver is more convenient

For advantage 1, you can refer to ESLint’s thoughts on the introduction of flat config:

> Use native loading for everything - one of our biggest regrets about eslintrc was recreating the Node.js require resolution in a custom way. This was a significant source of complexity and, in hindsight, unnecessary. Going forward, we wanted to leverage the loading capabilities of the JavaScript runtime directly.

Reference: https://eslint.org/blog/2022/08/new-config-system-part-2/#the-goals-of-flat-config

For advantage 2, there may be cases when existing resolvers don't meet the needs and needs to be temporarily added.

In this case, if the direct use of objects is supported, the steps of "creating new file -> processing filesystem path" can be eliminated, and the object can be created in place and used immediately.

For example, suppose I want to bundle the eslint configuration and all dependencies into a single file for use. In this case, none of the above three configuration methods will work. However, if the feature of directly using the resolver object is provided, my needs can be well met.

In order to support the use of `Resolver` objects, the configuration structure of the `import-x/resolver` configuration item needs to be modified.

Before:
```ts
export type ImportResolver =
  | LiteralUnion<'node' | 'typescript' | 'webpack', string>
  | {
      node?: boolean | NodeResolverOptions
      typescript?: boolean | TsResolverOptions
      webpack?: WebpackResolverOptions
      [resolve: string]: unknown
    }
```

After:
```ts
export type Resolver = {
  interfaceVersion?: 1 | 2
  resolve: ResolverResolve
  resolveImport: ResolverResolveImport
}

export type ResolverName = LiteralUnion<
  'node' | 'typescript' | 'webpack',
  string
>

export type ResolverRecord = {
  node?: boolean | NodeResolverOptions
  typescript?: boolean | TsResolverOptions
  webpack?: WebpackResolverOptions
  [resolve: string]: unknown
}

export type ResolverObject = {
  // node, typescript, webpack...
  name: ResolverName

  // Enabled by default
  enable?: boolean

  // Options passed to the resolver
  options?:
    | NodeResolverOptions
    | TsResolverOptions
    | WebpackResolverOptions
    | unknown

  // Any object satisfied Resolver type
  resolver: Resolver
}

export type ImportResolver =
  | ResolverName
  | ResolverRecord
  | ResolverObject
  | ResolverName[]
  | ResolverRecord[]
  | ResolverObject[]
```

Pull request:
feat: add support for using resolver object directly in settings
close #75

Patch:
diff --git a/.changeset/red-rings-pay.md b/.changeset/red-rings-pay.md
new file mode 100644
index 00000000..c563ec62
--- /dev/null
+++ b/.changeset/red-rings-pay.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": minor
+---
+
+feat: add support for using resolver object directly in settings
diff --git a/README.md b/README.md
index dfdf409c..f51f99f6 100644
--- a/README.md
+++ b/README.md
@@ -298,6 +298,40 @@ module.exports = {
 }
 ```
 
+- use the `import` or `require` syntax to directly import the resolver object:
+
+```js
+// .eslintrc.mjs
+import tsResolver from 'eslint-import-resolver-typescript'
+
+export default {
+  settings: {
+    'import-x/resolver': {
+      name: 'tsResolver', // required, could be any string you like
+      // enable: false, // optional, defaults to true
+      options: { someConfig: value }, // optional, options to pass to the resolver
+      resolver: tsResolver, // required, the resolver object
+    },
+  },
+}
+```
+
+```js
+// .eslintrc.cjs
+const tsResolver = require('eslint-import-resolver-typescript')
+
+module.exports = {
+  settings: {
+    'import-x/resolver': {
+      name: 'tsResolver', // required, could be any string you like
+      // enable: false, // optional, defaults to true
+      options: { someConfig: value }, // optional, options to pass to the resolver
+      resolver: tsResolver, // required, the resolver object
+    },
+  },
+}
+```
+
 Relative paths will be resolved relative to the source's nearest `package.json` or
 the process's current working directory if no `package.json` is found.
 
diff --git a/src/types.ts b/src/types.ts
index 5a0c88d4..eee0cdac 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -32,14 +32,73 @@ export type DocStyle = 'jsdoc' | 'tomdoc'
 
 export type Arrayable<T> = T | readonly T[]
 
+export type ResultNotFound = {
+  found: false
+  path?: undefined
+}
+
+export type ResultFound = {
+  found: true
+  path: string | null
+}
+
+export type ResolvedResult = ResultNotFound | ResultFound
+
+export type ResolverResolve<T = unknown> = (
+  modulePath: string,
+  sourceFile: string,
+  config: T,
+) => ResolvedResult
+
+export type ResolverResolveImport<T = unknown> = (
+  modulePath: string,
+  sourceFile: string,
+  config: T,
+) => string | undefined
+
+export type Resolver<T = unknown, U = T> = {
+  interfaceVersion?: 1 | 2
+  resolve: ResolverResolve<T>
+  resolveImport: ResolverResolveImport<U>
+}
+
+export type ResolverName = LiteralUnion<
+  'node' | 'typescript' | 'webpack',
+  string
+>
+
+export type ResolverRecord = {
+  node?: boolean | NodeResolverOptions
+  typescript?: boolean | TsResolverOptions
+  webpack?: WebpackResolverOptions
+  [resolve: string]: unknown
+}
+
+export type ResolverObject = {
+  // node, typescript, webpack...
+  name: ResolverName
+
+  // Enabled by default
+  enable?: boolean
+
+  // Options passed to the resolver
+  options?:
+    | NodeResolverOptions
+    | TsResolverOptions
+    | WebpackResolverOptions
+    | unknown
+
+  // Any object satisfied Resolver type
+  resolver: Resolver
+}
+
 export type ImportResolver =
-  | LiteralUnion<'node' | 'typescript' | 'webpack', string>
-  | {
-      node?: boolean | NodeResolverOptions
-      typescript?: boolean | TsResolverOptions
-      webpack?: WebpackResolverOptions
-      [resolve: string]: unknown
-    }
+  | ResolverName
+  | ResolverRecord
+  | ResolverObject
+  | ResolverName[]
+  | ResolverRecord[]
+  | ResolverObject[]
 
 export type ImportSettings = {
   cache?: {
@@ -53,7 +112,7 @@ export type ImportSettings = {
   internalRegex?: string
   parsers?: Record<string, readonly FileExtension[]>
   resolve?: NodeResolverOptions
-  resolver?: Arrayable<ImportResolver>
+  resolver?: ImportResolver
 }
 
 export type WithPluginName<T extends string | object> = T extends string
diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts
index 2132be83..805bd775 100644
--- a/src/utils/resolve.ts
+++ b/src/utils/resolve.ts
@@ -5,46 +5,18 @@ import path from 'node:path'
 import stableHash from 'stable-hash'
 
 import type {
-  Arrayable,
-  ImportResolver,
   ImportSettings,
   PluginSettings,
   RuleContext,
+  Resolver,
+  ImportResolver,
+  ResolverRecord,
+  ResolverObject,
 } from '../types'
 
 import { ModuleCache } from './module-cache'
 import { pkgDir } from './pkg-dir'
 
-export type ResultNotFound = {
-  found: false
-  path?: undefined
-}
-
-export type ResultFound = {
-  found: true
-  path: string | null
-}
-
-export type ResolvedResult = ResultNotFound | ResultFound
-
-export type ResolverResolve = (
-  modulePath: string,
-  sourceFile: string,
-  config: unknown,
-) => ResolvedResult
-
-export type ResolverResolveImport = (
-  modulePath: string,
-  sourceFile: string,
-  config: unknown,
-) => string | undefined
-
-export type Resolver = {
-  interfaceVersion?: 1 | 2
-  resolve: ResolverResolve
-  resolveImport: ResolverResolveImport
-}
-
 export const CASE_SENSITIVE_FS = !fs.existsSync(
   path.resolve(
     __dirname,
@@ -184,11 +156,14 @@ function fullResolve(
     node: settings['import-x/resolve'],
   } // backward compatibility
 
-  const resolvers = resolverReducer(configResolvers, new Map())
+  const resolvers = normalizeConfigResolvers(configResolvers, sourceFile)
+
+  for (const { enable, options, resolver } of resolvers) {
+    if (!enable) {
+      continue
+    }
 
-  for (const [name, config] of resolvers) {
-    const resolver = requireResolver(name, sourceFile)
-    const resolved = withResolver(resolver, config)
+    const resolved = withResolver(resolver, options)
 
     if (!resolved.found) {
       continue
@@ -212,30 +187,58 @@ export function relative(
   return fullResolve(modulePath, sourceFile, settings).path
 }
 
-function resolverReducer(
-  resolvers: Arrayable<ImportResolver>,
-  map: Map<string, unknown>,
+function normalizeConfigResolvers(
+  resolvers: ImportResolver,
+  sourceFile: string,
 ) {
-  if (Array.isArray(resolvers)) {
-    for (const r of resolvers as ImportResolver[]) resolverReducer(r, map)
-    return map
-  }
-
-  if (typeof resolvers === 'string') {
-    map.set(resolvers, null)
-    return map
-  }
-
-  if (typeof resolvers === 'object') {
-    for (const [key, value] of Object.entries(resolvers)) {
-      map.set(key, value)
+  const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers]
+  const map = new Map<string, Required<ResolverObject>>()
+
+  for (const nameOrRecordOrObject of resolverArray) {
+    if (typeof nameOrRecordOrObject === 'string') {
+      const name = nameOrRecordOrObject
+
+      map.set(name, {
+        name,
+        enable: true,
+        options: undefined,
+        resolver: requireResolver(name, sourceFile),
+      })
+    } else if (typeof nameOrRecordOrObject === 'object') {
+      if (nameOrRecordOrObject.name && nameOrRecordOrObject.resolver) {
+        const object = nameOrRecordOrObject as ResolverObject
+
+        const { name, enable = true, options, resolver } = object
+        map.set(name, { name, enable, options, resolver })
+      } else {
+        const record = nameOrRecordOrObject as ResolverRecord
+
+        for (const [name, enableOrOptions] of Object.entries(record)) {
+          if (typeof enableOrOptions === 'boolean') {
+            map.set(name, {
+              name,
+              enable: enableOrOptions,
+              options: undefined,
+              resolver: requireResolver(name, sourceFile),
+            })
+          } else {
+            map.set(name, {
+              name,
+              enable: true,
+              options: enableOrOptions,
+              resolver: requireResolver(name, sourceFile),
+            })
+          }
+        }
+      }
+    } else {
+      const err = new Error('invalid resolver config')
+      err.name = ERROR_NAME
+      throw err
     }
-    return map
   }
 
-  const err = new Error('invalid resolver config')
-  err.name = ERROR_NAME
-  throw err
+  return [...map.values()]
 }
 
 function getBaseDir(sourceFile: string): string {


Test patch:
diff --git a/test/fixtures/node_modules/eslint-import-resolver-foo-v1/index.js b/test/fixtures/node_modules/eslint-import-resolver-foo-v1/index.js
new file mode 120000
index 00000000..867bba70
--- /dev/null
+++ b/test/fixtures/node_modules/eslint-import-resolver-foo-v1/index.js
@@ -0,0 +1,1 @@
+../../foo-bar-resolver-v1.js
\ No newline at end of file
diff --git a/test/fixtures/node_modules/eslint-import-resolver-foo-v1/package.json b/test/fixtures/node_modules/eslint-import-resolver-foo-v1/package.json
new file mode 100644
index 00000000..b6bc0bfb
--- /dev/null
+++ b/test/fixtures/node_modules/eslint-import-resolver-foo-v1/package.json
@@ -0,0 +1,3 @@
+{
+  "name": "eslint-import-resolver-foo-v1"
+}
diff --git a/test/fixtures/node_modules/eslint-import-resolver-foo/index.js b/test/fixtures/node_modules/eslint-import-resolver-foo-v2/index.js
similarity index 100%
rename from test/fixtures/node_modules/eslint-import-resolver-foo/index.js
rename to test/fixtures/node_modules/eslint-import-resolver-foo-v2/index.js
diff --git a/test/fixtures/node_modules/eslint-import-resolver-foo-v2/package.json b/test/fixtures/node_modules/eslint-import-resolver-foo-v2/package.json
new file mode 100644
index 00000000..6b68f8e4
--- /dev/null
+++ b/test/fixtures/node_modules/eslint-import-resolver-foo-v2/package.json
@@ -0,0 +1,3 @@
+{
+  "name": "eslint-import-resolver-foo-v2"
+}
diff --git a/test/fixtures/node_modules/eslint-import-resolver-foo/package.json b/test/fixtures/node_modules/eslint-import-resolver-foo/package.json
deleted file mode 100644
index 190e8e6e..00000000
--- a/test/fixtures/node_modules/eslint-import-resolver-foo/package.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "name": "eslint-import-resolver-foo"
-}
diff --git a/test/utils/resolve.spec.ts b/test/utils/resolve.spec.ts
index 3b920bc1..2bc7a63f 100644
--- a/test/utils/resolve.spec.ts
+++ b/test/utils/resolve.spec.ts
@@ -104,39 +104,6 @@ describe('resolve', () => {
     expect(testContextReports.length).toBe(0)
   })
 
-  it('respects import-x/resolver as array of strings', () => {
-    const context = testContext({
-      'import-x/resolver': ['./foo-bar-resolver-v2', './foo-bar-resolver-v1'],
-    })
-
-    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
-  })
-
-  it('respects import-x/resolver as object', () => {
-    const context = testContext({
-      'import-x/resolver': { './foo-bar-resolver-v2': {} },
-    })
-
-    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
-  })
-
-  it('respects import-x/resolver as array of objects', () => {
-    const context = testContext({
-      'import-x/resolver': [
-        { './foo-bar-resolver-v2': {} },
-        { './foo-bar-resolver-v1': {} },
-      ],
-    })
-
-    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
-  })
-
-  it('finds resolvers from the source files rather than eslint-plugin-import-x/utils', () => {
-    const context = testContext({ 'import-x/resolver': { foo: {} } })
-
-    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
-  })
-
   it('reports invalid import-x/resolver config', () => {
     const context = testContext({
       // @ts-expect-error - testing
@@ -300,52 +267,6 @@ describe('resolve', () => {
         expect(testContextReports.length).toBe(0)
       })
 
-      it('respects import-x/resolver as array of strings', () => {
-        const context = testContext({
-          'import-x/resolver': [
-            './foo-bar-resolver-v2',
-            './foo-bar-resolver-v1',
-          ],
-        })
-
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
-      })
-
-      it('respects import-x/resolver as object', () => {
-        const context = testContext({
-          'import-x/resolver': { './foo-bar-resolver-v2': {} },
-        })
-
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
-      })
-
-      it('respects import-x/resolver as array of objects', () => {
-        const context = testContext({
-          'import-x/resolver': [
-            { './foo-bar-resolver-v2': {} },
-            { './foo-bar-resolver-v1': {} },
-          ],
-        })
-
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
-      })
-
-      it('finds resolvers from the source files rather than eslint-plugin-import-x/utils', () => {
-        const context = testContext({
-          'import-x/resolver': { foo: {} },
-        })
-
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
-      })
-
       it('reports invalid import-x/resolver config', () => {
         const context = testContext({
           // @ts-expect-error - testing
@@ -539,4 +460,213 @@ describe('resolve', () => {
       })
     }
   })
+
+  describe('respects import-x/resolver', () => {
+    it('as resolver package name(s)', () => {
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({ 'import-x/resolver': { 'foo-v2': {} } }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': [{ 'foo-v2': {} }, { 'foo-v1': {} }],
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+    })
+
+    it('as resolver file path(s)', () => {
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': './foo-bar-resolver-v2',
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': [
+              './foo-bar-resolver-v2',
+              './foo-bar-resolver-v1',
+            ],
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+    })
+
+    it('as resolver record(s)', () => {
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': { './foo-bar-resolver-v2': {} },
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': [
+              { './foo-bar-resolver-v2': {} },
+              { './foo-bar-resolver-v1': {} },
+            ],
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+    })
+
+    it('as resolver object(s)', async () => {
+      // @ts-expect-error - no types for resolver
+      const resolverV1 = await import('../fixtures/foo-bar-resolver-v1.js')
+      // @ts-expect-error - no types for resolver
+      const resolverV2 = await import('../fixtures/foo-bar-resolver-v2.js')
+
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': {
+              name: 'foo-bar-resolver-v2',
+              resolver: resolverV2,
+            },
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+
+      expect(
+        resolve(
+          '../fixtures/foo',
+          testContext({
+            'import-x/resolver': [
+              {
+                name: 'foo-bar-resolver-v2',
+                resolver: resolverV2,
+              },
+              {
+                name: 'foo-bar-resolver-v1',
+                resolver: resolverV1,
+              },
+            ],
+          }),
+        ),
+      ).toBe(testFilePath('./bar.jsx'))
+    })
+
+    // context.getPhysicalFilename() is available in ESLint 7.28+
+    ;(semver.satisfies(eslintPkg.version, '>= 7.28')
+      ? describe
+      : describe.skip)('getPhysicalFilename()', () => {
+      it('as resolver package name(s)', () => {
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({ 'import-x/resolver': { 'foo-v2': {} } }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': [{ 'foo-v2': {} }, { 'foo-v1': {} }],
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+      })
+
+      it('as resolver file path(s)', () => {
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': './foo-bar-resolver-v2',
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': [
+                './foo-bar-resolver-v2',
+                './foo-bar-resolver-v1',
+              ],
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+      })
+
+      it('as resolver record(s)', () => {
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': { './foo-bar-resolver-v2': {} },
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': [
+                { './foo-bar-resolver-v2': {} },
+                { './foo-bar-resolver-v1': {} },
+              ],
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+      })
+
+      it('as resolver object(s)', async () => {
+        // @ts-expect-error - no types for resolver
+        const resolverV1 = await import('../fixtures/foo-bar-resolver-v1.js')
+        // @ts-expect-error - no types for resolver
+        const resolverV2 = await import('../fixtures/foo-bar-resolver-v2.js')
+
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': {
+                name: 'foo-bar-resolver-v2',
+                resolver: resolverV2,
+              },
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+
+        expect(
+          resolve(
+            '../fixtures/foo',
+            testContext({
+              'import-x/resolver': [
+                {
+                  name: 'foo-bar-resolver-v2',
+                  resolver: resolverV2,
+                },
+                {
+                  name: 'foo-bar-resolver-v1',
+                  resolver: resolverV1,
+                },
+              ],
+            }),
+          ),
+        ).toBe(testFilePath('./bar.jsx'))
+      })
+    })
+  })
 })