Skip to content

Commit fbf639b

Browse files
authoredDec 3, 2024··
feat: implement custom resolver interface v3 (#192)
1 parent bc4de89 commit fbf639b

File tree

7 files changed

+811
-364
lines changed

7 files changed

+811
-364
lines changed
 

‎.changeset/orange-nails-attack.md

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
The PR implements the new resolver design proposed in https://github.com/un-ts/eslint-plugin-import-x/issues/40#issuecomment-2381444266
2+
3+
----
4+
5+
### For `eslint-plugin-import-x` users
6+
7+
Like the ESLint flat config allows you to use any js objects (e.g. import and require) as ESLint plugins, the new `eslint-plugin-import-x` resolver settings allow you to use any js objects as custom resolvers through the new setting `import-x/resolver-next`:
8+
9+
```js
10+
// eslint.config.js
11+
import { createTsResolver } from '#custom-resolver';
12+
const { createOxcResolver } = require('path/to/a/custom/resolver');
13+
14+
const nodeResolverObject = {
15+
interfaceVersion: 3,
16+
name: 'my-custom-eslint-import-resolver',
17+
resolve(modPath, sourcePath) {
18+
};
19+
};
20+
21+
module.exports = {
22+
settings: {
23+
// multiple resolvers
24+
'import-x/resolver-next': [
25+
nodeResolverObject,
26+
createTsResolver(enhancedResolverOptions),
27+
createOxcResolver(oxcOptions),
28+
],
29+
// single resolver:
30+
'import-x/resolver-next': [createOxcResolver(oxcOptions)]
31+
}
32+
}
33+
```
34+
35+
The new `import-x/resolver-next` no longer accepts strings as the resolver, thus will not be compatible with the ESLint legacy config (a.k.a. `.eslintrc`). Those who are still using the ESLint legacy config should stick with `import-x/resolver`.
36+
37+
In the next major version of `eslint-plugin-import-x` (v5), we will rename the currently existing `import-x/resolver` to `import-x/resolver-legacy` (which still allows the existing ESLint legacy config users to use their existing resolver settings), and `import-x/resolver-next` will become the new `import-x/resolver`. When ESLint v9 (the last ESLint version with ESLint legacy config support) reaches EOL in the future, we will remove `import-x/resolver-legacy`.
38+
39+
We have also made a few breaking changes to the new resolver API design, so you can't use existing custom resolvers directly with `import-x/resolver-next`:
40+
41+
```js
42+
// An example of the current `import-x/resolver` settings
43+
module.exports = {
44+
settings: {
45+
'import-x/resolver': {
46+
node: nodeResolverOpt
47+
webpack: webpackResolverOpt,
48+
'custom-resolver': customResolverOpt
49+
}
50+
}
51+
}
52+
53+
// When migrating to `import-x/resolver-next`, you CAN'T use legacy versions of resolvers directly:
54+
module.exports = {
55+
settings: {
56+
// THIS WON'T WORK, the resolver interface required for `import-x/resolver-next` is different.
57+
'import-x/resolver-next': [
58+
require('eslint-import-resolver-node'),
59+
require('eslint-import-resolver-webpack'),
60+
require('some-custom-resolver')
61+
];
62+
}
63+
}
64+
```
65+
66+
For easier migration, the PR also introduces a compat utility `importXResolverCompat` that you can use in your `eslint.config.js`:
67+
68+
```js
69+
// eslint.config.js
70+
import eslintPluginImportX, { importXResolverCompat } from 'eslint-plugin-import-x';
71+
// or
72+
const eslintPluginImportX = require('eslint-plugin-import-x');
73+
const { importXResolverCompat } = eslintPluginImportX;
74+
75+
module.exports = {
76+
settings: {
77+
// THIS WILL WORK as you have wrapped the previous version of resolvers with the `importXResolverCompat`
78+
'import-x/resolver-next': [
79+
importXResolverCompat(require('eslint-import-resolver-node'), nodeResolveOptions),
80+
importXResolverCompat(require('eslint-import-resolver-webpack'), webpackResolveOptions),
81+
importXResolverCompat(require('some-custom-resolver'), {})
82+
];
83+
}
84+
}
85+
```
86+
87+
### For custom import resolver developers
88+
89+
This is the new API design of the resolver interface:
90+
91+
```ts
92+
export interface NewResolver {
93+
interfaceVersion: 3,
94+
name?: string, // This will be included in the debug log
95+
resolve: (modulePath: string, sourceFile: string) => ResolvedResult
96+
}
97+
98+
// The `ResultNotFound` (returned when not resolved) is the same, no changes
99+
export interface ResultNotFound {
100+
found: false
101+
path?: undefined
102+
}
103+
104+
// The `ResultFound` (returned resolve result) is also the same, no changes
105+
export interface ResultFound {
106+
found: true
107+
path: string | null
108+
}
109+
110+
export type ResolvedResult = ResultNotFound | ResultFound
111+
```
112+
113+
You will be able to import `NewResolver` from `eslint-plugin-import-x/types`.
114+
115+
The most notable change is that `eslint-plugin-import-x` no longer passes the third argument (`options`) to the `resolve` function.
116+
117+
We encourage custom resolvers' authors to consume the options outside the actual `resolve` function implementation. You can export a factory function to accept the options, this factory function will then be called inside the `eslint.config.js` to get the actual resolver:
118+
119+
```js
120+
// custom-resolver.js
121+
exports.createCustomResolver = (options) => {
122+
// The options are consumed outside the `resolve` function.
123+
const resolverInstance = new ResolverFactory(options);
124+
125+
return {
126+
name: 'custom-resolver',
127+
interfaceVersion: 3,
128+
resolve(mod, source) {
129+
const found = resolverInstance.resolve(mod, {});
130+
131+
// Of course, you still have access to the `options` variable here inside
132+
// the `resolve` function. That's the power of JavaScript Closures~
133+
}
134+
}
135+
};
136+
137+
// eslint.config.js
138+
const { createCustomResolver } = require('custom-resolver')
139+
140+
module.exports = {
141+
settings: {
142+
'import-x/resolver-next': [
143+
createCustomResolver(options)
144+
];
145+
}
146+
}
147+
```
148+
149+
This allows you to create a reusable resolver instance to improve the performance. With the existing version of the resolver interface, because the options are passed to the `resolver` function, you will have to create a resolver instance every time the `resolve` function is called:
150+
151+
```js
152+
module.exports = {
153+
interfaceVersion: 2,
154+
resolve(mod, source) {
155+
// every time the `resolve` function is called, a new instance is created
156+
// This is very slow
157+
const resolverInstance = ResolverFactory.createResolver({});
158+
const found = resolverInstance.resolve(mod, {});
159+
}
160+
}
161+
```
162+
163+
With the factory function pattern, you can create a resolver instance beforehand:
164+
165+
```js
166+
exports.createCustomResolver = (options) => {
167+
// `enhance-resolve` allows you to create a reusable instance:
168+
const resolverInstance = ResolverFactory.createResolver({});
169+
const resolverInstance = enhanceResolve.create({});
170+
171+
// `oxc-resolver` also allows you to create a reusable instance:
172+
const resolverInstance = new ResolverFactory({});
173+
174+
return {
175+
name: 'custom-resolver',
176+
interfaceVersion: 3,
177+
resolve(mod, source) {
178+
// the same re-usable instance is shared across `resolve` invocations.
179+
// more performant
180+
const found = resolverInstance.resolve(mod, {});
181+
}
182+
}
183+
};
184+
```

‎src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import type {
7171
PluginFlatBaseConfig,
7272
PluginFlatConfig,
7373
} from './types'
74+
import { importXResolverCompat } from './utils'
7475

7576
const rules = {
7677
'no-unresolved': noUnresolved,
@@ -181,4 +182,5 @@ export = {
181182
configs,
182183
flatConfigs,
183184
rules,
185+
importXResolverCompat,
184186
}

‎src/types.ts

+46-58
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
22
import type { ResolveOptions } from 'enhanced-resolve'
33
import type { MinimatchOptions } from 'minimatch'
4-
import type { KebabCase, LiteralUnion } from 'type-fest'
4+
import type { KebabCase } from 'type-fest'
55

66
import type { ImportType as ImportType_, PluginName } from './utils'
7+
import type {
8+
LegacyImportResolver,
9+
LegacyResolver,
10+
} from './utils/legacy-resolver-settings'
11+
12+
export type {
13+
LegacyResolver,
14+
// ResolverName
15+
LegacyResolverName,
16+
LegacyResolverName as ResolverName,
17+
// ImportResolver
18+
LegacyImportResolver,
19+
LegacyImportResolver as ImportResolver,
20+
// ResolverResolve
21+
LegacyResolverResolve,
22+
LegacyResolverResolve as ResolverResolve,
23+
// ResolverResolveImport
24+
LegacyResolverResolveImport,
25+
LegacyResolverResolveImport as ResolverResolveImport,
26+
// ResolverRecord
27+
LegacyResolverRecord,
28+
LegacyResolverRecord as ResolverRecord,
29+
// ResolverObject
30+
LegacyResolverObject,
31+
LegacyResolverObject as ResolverObject,
32+
} from './utils/legacy-resolver-settings'
733

834
export type ImportType = ImportType_ | 'object' | 'type'
935

@@ -26,6 +52,20 @@ export type TsResolverOptions = {
2652
extensions?: string[]
2753
} & Omit<ResolveOptions, 'fileSystem' | 'useSyncFileSystemCalls'>
2854

55+
// TODO: remove prefix New in the next major version
56+
export type NewResolverResolve = (
57+
modulePath: string,
58+
sourceFile: string,
59+
) => ResolvedResult
60+
61+
// TODO: remove prefix New in the next major version
62+
export type NewResolver = {
63+
interfaceVersion: 3
64+
/** optional name for the resolver, this is used in logs/debug output */
65+
name?: string
66+
resolve: NewResolverResolve
67+
}
68+
2969
export type FileExtension = `.${string}`
3070

3171
export type DocStyle = 'jsdoc' | 'tomdoc'
@@ -42,63 +82,9 @@ export type ResultFound = {
4282
path: string | null
4383
}
4484

45-
export type ResolvedResult = ResultNotFound | ResultFound
46-
47-
export type ResolverResolve<T = unknown> = (
48-
modulePath: string,
49-
sourceFile: string,
50-
config: T,
51-
) => ResolvedResult
52-
53-
export type ResolverResolveImport<T = unknown> = (
54-
modulePath: string,
55-
sourceFile: string,
56-
config: T,
57-
) => string | undefined
58-
59-
export type Resolver<T = unknown, U = T> = {
60-
interfaceVersion?: 1 | 2
61-
resolve: ResolverResolve<T>
62-
resolveImport: ResolverResolveImport<U>
63-
}
64-
65-
export type ResolverName = LiteralUnion<
66-
'node' | 'typescript' | 'webpack',
67-
string
68-
>
69-
70-
export type ResolverRecord = {
71-
node?: boolean | NodeResolverOptions
72-
typescript?: boolean | TsResolverOptions
73-
webpack?: WebpackResolverOptions
74-
[resolve: string]: unknown
75-
}
85+
export type Resolver = LegacyResolver | NewResolver
7686

77-
export type ResolverObject = {
78-
// node, typescript, webpack...
79-
name: ResolverName
80-
81-
// Enabled by default
82-
enable?: boolean
83-
84-
// Options passed to the resolver
85-
options?:
86-
| NodeResolverOptions
87-
| TsResolverOptions
88-
| WebpackResolverOptions
89-
| unknown
90-
91-
// Any object satisfied Resolver type
92-
resolver: Resolver
93-
}
94-
95-
export type ImportResolver =
96-
| ResolverName
97-
| ResolverRecord
98-
| ResolverObject
99-
| ResolverName[]
100-
| ResolverRecord[]
101-
| ResolverObject[]
87+
export type ResolvedResult = ResultNotFound | ResultFound
10288

10389
export type ImportSettings = {
10490
cache?: {
@@ -112,7 +98,9 @@ export type ImportSettings = {
11298
internalRegex?: string
11399
parsers?: Record<string, readonly FileExtension[]>
114100
resolve?: NodeResolverOptions
115-
resolver?: ImportResolver
101+
resolver?: LegacyImportResolver
102+
'resolver-legacy'?: LegacyImportResolver
103+
'resolver-next'?: NewResolver[]
116104
}
117105

118106
export type WithPluginName<T extends string | object> = T extends string

‎src/utils/legacy-resolver-settings.ts

+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Although the new import resolver settings is still `import-x/resolver-next`, but it won't stop us from calling existing ones legacy~
2+
3+
import { createRequire } from 'node:module'
4+
import path from 'node:path'
5+
6+
import type { LiteralUnion } from 'type-fest'
7+
8+
import type {
9+
NodeResolverOptions,
10+
ResolvedResult,
11+
TsResolverOptions,
12+
WebpackResolverOptions,
13+
} from '../types'
14+
15+
import { pkgDir } from './pkg-dir'
16+
import { IMPORT_RESOLVE_ERROR_NAME } from './resolve'
17+
18+
export type LegacyResolverName = LiteralUnion<
19+
'node' | 'typescript' | 'webpack',
20+
string
21+
>
22+
23+
export type LegacyResolverResolveImport<T = unknown> = (
24+
modulePath: string,
25+
sourceFile: string,
26+
config: T,
27+
) => string | undefined
28+
29+
export type LegacyResolverResolve<T = unknown> = (
30+
modulePath: string,
31+
sourceFile: string,
32+
config: T,
33+
) => ResolvedResult
34+
35+
export type LegacyResolver<T = unknown, U = T> = {
36+
interfaceVersion?: 1 | 2
37+
resolve: LegacyResolverResolve<T>
38+
resolveImport: LegacyResolverResolveImport<U>
39+
}
40+
41+
export type LegacyResolverObject = {
42+
// node, typescript, webpack...
43+
name: LegacyResolverName
44+
45+
// Enabled by default
46+
enable?: boolean
47+
48+
// Options passed to the resolver
49+
options?:
50+
| NodeResolverOptions
51+
| TsResolverOptions
52+
| WebpackResolverOptions
53+
| unknown
54+
55+
// Any object satisfied Resolver type
56+
resolver: LegacyResolver
57+
}
58+
59+
export type LegacyResolverRecord = {
60+
node?: boolean | NodeResolverOptions
61+
typescript?: boolean | TsResolverOptions
62+
webpack?: WebpackResolverOptions
63+
[resolve: string]: unknown
64+
}
65+
66+
export type LegacyImportResolver =
67+
| LegacyResolverName
68+
| LegacyResolverRecord
69+
| LegacyResolverObject
70+
| LegacyResolverName[]
71+
| LegacyResolverRecord[]
72+
| LegacyResolverObject[]
73+
74+
export function resolveWithLegacyResolver(
75+
resolver: LegacyResolver,
76+
config: unknown,
77+
modulePath: string,
78+
sourceFile: string,
79+
): ResolvedResult {
80+
if (resolver.interfaceVersion === 2) {
81+
return resolver.resolve(modulePath, sourceFile, config)
82+
}
83+
84+
try {
85+
const resolved = resolver.resolveImport(modulePath, sourceFile, config)
86+
if (resolved === undefined) {
87+
return {
88+
found: false,
89+
}
90+
}
91+
return {
92+
found: true,
93+
path: resolved,
94+
}
95+
} catch {
96+
return {
97+
found: false,
98+
}
99+
}
100+
}
101+
102+
export function normalizeConfigResolvers(
103+
resolvers: LegacyImportResolver,
104+
sourceFile: string,
105+
) {
106+
const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers]
107+
const map = new Map<string, Required<LegacyResolverObject>>()
108+
109+
for (const nameOrRecordOrObject of resolverArray) {
110+
if (typeof nameOrRecordOrObject === 'string') {
111+
const name = nameOrRecordOrObject
112+
113+
map.set(name, {
114+
name,
115+
enable: true,
116+
options: undefined,
117+
resolver: requireResolver(name, sourceFile),
118+
})
119+
} else if (typeof nameOrRecordOrObject === 'object') {
120+
if (nameOrRecordOrObject.name && nameOrRecordOrObject.resolver) {
121+
const object = nameOrRecordOrObject as LegacyResolverObject
122+
123+
const { name, enable = true, options, resolver } = object
124+
map.set(name, { name, enable, options, resolver })
125+
} else {
126+
const record = nameOrRecordOrObject as LegacyResolverRecord
127+
128+
for (const [name, enableOrOptions] of Object.entries(record)) {
129+
if (typeof enableOrOptions === 'boolean') {
130+
map.set(name, {
131+
name,
132+
enable: enableOrOptions,
133+
options: undefined,
134+
resolver: requireResolver(name, sourceFile),
135+
})
136+
} else {
137+
map.set(name, {
138+
name,
139+
enable: true,
140+
options: enableOrOptions,
141+
resolver: requireResolver(name, sourceFile),
142+
})
143+
}
144+
}
145+
}
146+
} else {
147+
const err = new Error('invalid resolver config')
148+
err.name = IMPORT_RESOLVE_ERROR_NAME
149+
throw err
150+
}
151+
}
152+
153+
return [...map.values()]
154+
}
155+
156+
function requireResolver(name: string, sourceFile: string) {
157+
// Try to resolve package with conventional name
158+
const resolver =
159+
tryRequire(`eslint-import-resolver-${name}`, sourceFile) ||
160+
tryRequire(name, sourceFile) ||
161+
tryRequire(path.resolve(getBaseDir(sourceFile), name))
162+
163+
if (!resolver) {
164+
const err = new Error(`unable to load resolver "${name}".`)
165+
err.name = IMPORT_RESOLVE_ERROR_NAME
166+
throw err
167+
}
168+
if (!isLegacyResolverValid(resolver)) {
169+
const err = new Error(`${name} with invalid interface loaded as resolver`)
170+
err.name = IMPORT_RESOLVE_ERROR_NAME
171+
throw err
172+
}
173+
174+
return resolver
175+
}
176+
177+
function isLegacyResolverValid(resolver: object): resolver is LegacyResolver {
178+
if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) {
179+
return (
180+
'resolve' in resolver &&
181+
!!resolver.resolve &&
182+
typeof resolver.resolve === 'function'
183+
)
184+
}
185+
return (
186+
'resolveImport' in resolver &&
187+
!!resolver.resolveImport &&
188+
typeof resolver.resolveImport === 'function'
189+
)
190+
}
191+
192+
function tryRequire<T>(
193+
target: string,
194+
sourceFile?: string | null,
195+
): undefined | T {
196+
let resolved
197+
try {
198+
// Check if the target exists
199+
if (sourceFile == null) {
200+
resolved = require.resolve(target)
201+
} else {
202+
try {
203+
resolved = createRequire(path.resolve(sourceFile)).resolve(target)
204+
} catch {
205+
resolved = require.resolve(target)
206+
}
207+
}
208+
} catch {
209+
// If the target does not exist then just return undefined
210+
return undefined
211+
}
212+
213+
// If the target exists then return the loaded module
214+
return require(resolved)
215+
}
216+
217+
function getBaseDir(sourceFile: string): string {
218+
return pkgDir(sourceFile) || process.cwd()
219+
}

‎src/utils/resolve.ts

+118-164
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import fs from 'node:fs'
2-
import { createRequire } from 'node:module'
32
import path from 'node:path'
43

54
import stableHash from 'stable-hash'
65

76
import type {
87
ImportSettings,
8+
LegacyResolver,
9+
NewResolver,
910
PluginSettings,
1011
RuleContext,
11-
Resolver,
12-
ImportResolver,
13-
ResolverRecord,
14-
ResolverObject,
1512
} from '../types'
1613

14+
import {
15+
normalizeConfigResolvers,
16+
resolveWithLegacyResolver,
17+
} from './legacy-resolver-settings'
1718
import { ModuleCache } from './module-cache'
18-
import { pkgDir } from './pkg-dir'
1919

2020
export const CASE_SENSITIVE_FS = !fs.existsSync(
2121
path.resolve(
@@ -24,34 +24,9 @@ export const CASE_SENSITIVE_FS = !fs.existsSync(
2424
),
2525
)
2626

27-
const ERROR_NAME = 'EslintPluginImportResolveError'
27+
export const IMPORT_RESOLVE_ERROR_NAME = 'EslintPluginImportResolveError'
2828

29-
const fileExistsCache = new ModuleCache()
30-
31-
function tryRequire<T>(
32-
target: string,
33-
sourceFile?: string | null,
34-
): undefined | T {
35-
let resolved
36-
try {
37-
// Check if the target exists
38-
if (sourceFile == null) {
39-
resolved = require.resolve(target)
40-
} else {
41-
try {
42-
resolved = createRequire(path.resolve(sourceFile)).resolve(target)
43-
} catch {
44-
resolved = require.resolve(target)
45-
}
46-
}
47-
} catch {
48-
// If the target does not exist then just return undefined
49-
return undefined
50-
}
51-
52-
// If the target exists then return the loaded module
53-
return require(resolved)
54-
}
29+
export const fileExistsCache = new ModuleCache()
5530

5631
// https://stackoverflow.com/a/27382838
5732
export function fileExistsWithCaseSync(
@@ -95,6 +70,39 @@ export function fileExistsWithCaseSync(
9570
let prevSettings: PluginSettings | null = null
9671
let memoizedHash: string
9772

73+
function isNamedResolver(resolver: unknown): resolver is { name: string } {
74+
return !!(
75+
typeof resolver === 'object' &&
76+
resolver &&
77+
'name' in resolver &&
78+
typeof resolver.name === 'string' &&
79+
resolver.name
80+
)
81+
}
82+
83+
function isValidNewResolver(resolver: unknown): resolver is NewResolver {
84+
if (typeof resolver !== 'object' || resolver == null) {
85+
return false
86+
}
87+
88+
if (!('resolve' in resolver) || !('interfaceVersion' in resolver)) {
89+
return false
90+
}
91+
92+
if (
93+
typeof resolver.interfaceVersion !== 'number' ||
94+
resolver.interfaceVersion !== 3
95+
) {
96+
return false
97+
}
98+
99+
if (typeof resolver.resolve !== 'function') {
100+
return false
101+
}
102+
103+
return true
104+
}
105+
98106
function fullResolve(
99107
modulePath: string,
100108
sourceFile: string,
@@ -125,49 +133,63 @@ function fullResolve(
125133
return { found: true, path: cachedPath }
126134
}
127135

128-
function withResolver(resolver: Resolver, config: unknown) {
129-
if (resolver.interfaceVersion === 2) {
130-
return resolver.resolve(modulePath, sourceFile, config)
131-
}
132-
133-
try {
134-
const resolved = resolver.resolveImport(modulePath, sourceFile, config)
135-
if (resolved === undefined) {
136-
return {
137-
found: false,
138-
}
136+
if (
137+
Object.prototype.hasOwnProperty.call(settings, 'import-x/resolver-next') &&
138+
settings['import-x/resolver-next']
139+
) {
140+
const configResolvers = settings['import-x/resolver-next']
141+
142+
for (let i = 0, len = configResolvers.length; i < len; i++) {
143+
const resolver = configResolvers[i]
144+
const resolverName = isNamedResolver(resolver)
145+
? resolver.name
146+
: `settings['import-x/resolver-next'][${i}]`
147+
148+
if (!isValidNewResolver(resolver)) {
149+
const err = new TypeError(
150+
`${resolverName} is not a valid import resolver for eslint-plugin-import-x!`,
151+
)
152+
err.name = IMPORT_RESOLVE_ERROR_NAME
153+
throw err
139154
}
140-
return {
141-
found: true,
142-
path: resolved,
143-
}
144-
} catch {
145-
return {
146-
found: false,
147-
}
148-
}
149-
}
150155

151-
const configResolvers = settings['import-x/resolver'] || {
152-
node: settings['import-x/resolve'],
153-
} // backward compatibility
154-
155-
const resolvers = normalizeConfigResolvers(configResolvers, sourceFile)
156+
const resolved = resolver.resolve(modulePath, sourceFile)
157+
if (!resolved.found) {
158+
continue
159+
}
156160

157-
for (const { enable, options, resolver } of resolvers) {
158-
if (!enable) {
159-
continue
161+
// else, counts
162+
fileExistsCache.set(cacheKey, resolved.path as string | null)
163+
return resolved
160164
}
165+
} else {
166+
const configResolvers = settings['import-x/resolver-legacy'] ||
167+
settings['import-x/resolver'] || {
168+
node: settings['import-x/resolve'],
169+
} // backward compatibility
170+
171+
for (const { enable, options, resolver } of normalizeConfigResolvers(
172+
configResolvers,
173+
sourceFile,
174+
)) {
175+
if (!enable) {
176+
continue
177+
}
161178

162-
const resolved = withResolver(resolver, options)
179+
const resolved = resolveWithLegacyResolver(
180+
resolver,
181+
options,
182+
modulePath,
183+
sourceFile,
184+
)
185+
if (!resolved.found) {
186+
continue
187+
}
163188

164-
if (!resolved.found) {
165-
continue
189+
// else, counts
190+
fileExistsCache.set(cacheKey, resolved.path as string | null)
191+
return resolved
166192
}
167-
168-
// else, counts
169-
fileExistsCache.set(cacheKey, resolved.path as string | null)
170-
return resolved
171193
}
172194

173195
// failed
@@ -183,100 +205,6 @@ export function relative(
183205
return fullResolve(modulePath, sourceFile, settings).path
184206
}
185207

186-
function normalizeConfigResolvers(
187-
resolvers: ImportResolver,
188-
sourceFile: string,
189-
) {
190-
const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers]
191-
const map = new Map<string, Required<ResolverObject>>()
192-
193-
for (const nameOrRecordOrObject of resolverArray) {
194-
if (typeof nameOrRecordOrObject === 'string') {
195-
const name = nameOrRecordOrObject
196-
197-
map.set(name, {
198-
name,
199-
enable: true,
200-
options: undefined,
201-
resolver: requireResolver(name, sourceFile),
202-
})
203-
} else if (typeof nameOrRecordOrObject === 'object') {
204-
if (nameOrRecordOrObject.name && nameOrRecordOrObject.resolver) {
205-
const object = nameOrRecordOrObject as ResolverObject
206-
207-
const { name, enable = true, options, resolver } = object
208-
map.set(name, { name, enable, options, resolver })
209-
} else {
210-
const record = nameOrRecordOrObject as ResolverRecord
211-
212-
for (const [name, enableOrOptions] of Object.entries(record)) {
213-
if (typeof enableOrOptions === 'boolean') {
214-
map.set(name, {
215-
name,
216-
enable: enableOrOptions,
217-
options: undefined,
218-
resolver: requireResolver(name, sourceFile),
219-
})
220-
} else {
221-
map.set(name, {
222-
name,
223-
enable: true,
224-
options: enableOrOptions,
225-
resolver: requireResolver(name, sourceFile),
226-
})
227-
}
228-
}
229-
}
230-
} else {
231-
const err = new Error('invalid resolver config')
232-
err.name = ERROR_NAME
233-
throw err
234-
}
235-
}
236-
237-
return [...map.values()]
238-
}
239-
240-
function getBaseDir(sourceFile: string): string {
241-
return pkgDir(sourceFile) || process.cwd()
242-
}
243-
244-
function requireResolver(name: string, sourceFile: string) {
245-
// Try to resolve package with conventional name
246-
const resolver =
247-
tryRequire(`eslint-import-resolver-${name}`, sourceFile) ||
248-
tryRequire(name, sourceFile) ||
249-
tryRequire(path.resolve(getBaseDir(sourceFile), name))
250-
251-
if (!resolver) {
252-
const err = new Error(`unable to load resolver "${name}".`)
253-
err.name = ERROR_NAME
254-
throw err
255-
}
256-
if (!isResolverValid(resolver)) {
257-
const err = new Error(`${name} with invalid interface loaded as resolver`)
258-
err.name = ERROR_NAME
259-
throw err
260-
}
261-
262-
return resolver
263-
}
264-
265-
function isResolverValid(resolver: object): resolver is Resolver {
266-
if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) {
267-
return (
268-
'resolve' in resolver &&
269-
!!resolver.resolve &&
270-
typeof resolver.resolve === 'function'
271-
)
272-
}
273-
return (
274-
'resolveImport' in resolver &&
275-
!!resolver.resolveImport &&
276-
typeof resolver.resolveImport === 'function'
277-
)
278-
}
279-
280208
const erroredContexts = new Set<RuleContext>()
281209

282210
/**
@@ -294,7 +222,7 @@ export function resolve(p: string, context: RuleContext) {
294222
// The `err.stack` string starts with `err.name` followed by colon and `err.message`.
295223
// We're filtering out the default `err.name` because it adds little value to the message.
296224
let errMessage = error.message
297-
if (error.name !== ERROR_NAME && error.stack) {
225+
if (error.name !== IMPORT_RESOLVE_ERROR_NAME && error.stack) {
298226
errMessage = error.stack.replace(/^Error: /, '')
299227
}
300228
context.report({
@@ -309,3 +237,29 @@ export function resolve(p: string, context: RuleContext) {
309237
}
310238
}
311239
}
240+
241+
export function importXResolverCompat(
242+
resolver: LegacyResolver | NewResolver,
243+
resolverOptions: unknown = {},
244+
): NewResolver {
245+
// Somehow the resolver is already using v3 interface
246+
if (isValidNewResolver(resolver)) {
247+
return resolver
248+
}
249+
250+
return {
251+
// deliberately not providing the name, because we can't get the name from legacy resolvers
252+
// By omitting the name, the log will use identifiable name like `settings['import-x/resolver-next'][0]`
253+
// name: 'import-x-resolver-compat',
254+
interfaceVersion: 3,
255+
resolve: (modulePath, sourceFile) => {
256+
const resolved = resolveWithLegacyResolver(
257+
resolver,
258+
resolverOptions,
259+
modulePath,
260+
sourceFile,
261+
)
262+
return resolved
263+
},
264+
}
265+
}

‎test/fixtures/foo-bar-resolver-v3.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
var path = require('path')
2+
3+
exports.foobarResolver = (/** @type {import('eslint-plugin-import-x/types').NewResolver} */ {
4+
name: 'resolver-foo-bar',
5+
interfaceVersion: 3,
6+
resolve: function (modulePath, sourceFile) {
7+
var sourceFileName = path.basename(sourceFile)
8+
if (sourceFileName === 'foo.js') {
9+
return { found: true, path: path.join(__dirname, 'bar.jsx') }
10+
}
11+
if (sourceFileName === 'exception.js') {
12+
throw new Error('foo-bar-resolver-v3 resolve test exception')
13+
}
14+
return { found: false }
15+
}
16+
})

‎test/utils/resolve.spec.ts

+226-142
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@ import path from 'node:path'
33
import { setTimeout } from 'node:timers/promises'
44

55
import type { TSESLint } from '@typescript-eslint/utils'
6-
import eslintPkg from 'eslint/package.json'
7-
import semver from 'semver'
86

97
import { testContext, testFilePath } from '../utils'
108

9+
import eslintPluginImportX from 'eslint-plugin-import-x'
1110
import {
1211
CASE_SENSITIVE_FS,
1312
fileExistsWithCaseSync,
1413
resolve,
1514
} from 'eslint-plugin-import-x/utils'
1615

16+
const { importXResolverCompat } = eslintPluginImportX
17+
1718
describe('resolve', () => {
1819
it('throws on bad parameters', () => {
1920
expect(
@@ -104,6 +105,58 @@ describe('resolve', () => {
104105
expect(testContextReports.length).toBe(0)
105106
})
106107

108+
it('resolves via a custom resolver with interface version 3', () => {
109+
const context = testContext({
110+
'import-x/resolver-next': [
111+
require('../fixtures/foo-bar-resolver-v3').foobarResolver,
112+
],
113+
})
114+
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
115+
context.report = reportInfo => {
116+
testContextReports.push(reportInfo)
117+
}
118+
119+
expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
120+
121+
testContextReports.length = 0
122+
expect(
123+
resolve('../fixtures/exception', {
124+
...context,
125+
physicalFilename: testFilePath('exception.js'),
126+
}),
127+
).toBeUndefined()
128+
expect(testContextReports[0]).toBeInstanceOf(Object)
129+
expect(
130+
'message' in testContextReports[0] && testContextReports[0].message,
131+
).toMatch('Resolve error: foo-bar-resolver-v3 resolve test exception')
132+
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
133+
134+
testContextReports.length = 0
135+
expect(
136+
resolve('../fixtures/not-found', {
137+
...context,
138+
physicalFilename: testFilePath('not-found.js'),
139+
}),
140+
).toBeUndefined()
141+
expect(testContextReports.length).toBe(0)
142+
})
143+
144+
it('importXResolverCompat()', () => {
145+
let context = testContext({
146+
'import-x/resolver-next': [
147+
importXResolverCompat(require('../fixtures/foo-bar-resolver-v2')),
148+
],
149+
})
150+
expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
151+
152+
context = testContext({
153+
'import-x/resolver-next': [
154+
importXResolverCompat(require('../fixtures/foo-bar-resolver-v1')),
155+
],
156+
})
157+
expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
158+
})
159+
107160
it('reports invalid import-x/resolver config', () => {
108161
const context = testContext({
109162
// @ts-expect-error - testing
@@ -179,165 +232,199 @@ describe('resolve', () => {
179232
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
180233
})
181234

182-
// context.getPhysicalFilename() is available in ESLint 7.28+
183-
;(semver.satisfies(eslintPkg.version, '>= 7.28') ? describe : describe.skip)(
184-
'getPhysicalFilename()',
185-
() => {
186-
it('resolves via a custom resolver with interface version 1', () => {
187-
const context = testContext({
188-
'import-x/resolver': './foo-bar-resolver-v1',
189-
})
235+
describe('getPhysicalFilename()', () => {
236+
it('resolves via a custom resolver with interface version 1', () => {
237+
const context = testContext({
238+
'import-x/resolver': './foo-bar-resolver-v1',
239+
})
190240

191-
expect(resolve('../fixtures/foo', context)).toBe(
192-
testFilePath('./bar.jsx'),
193-
)
241+
expect(resolve('../fixtures/foo', context)).toBe(
242+
testFilePath('./bar.jsx'),
243+
)
194244

195-
expect(
196-
resolve('../fixtures/exception', {
197-
...context,
198-
physicalFilename: testFilePath('exception.js'),
199-
}),
200-
).toBeUndefined()
245+
expect(
246+
resolve('../fixtures/exception', {
247+
...context,
248+
physicalFilename: testFilePath('exception.js'),
249+
}),
250+
).toBeUndefined()
201251

202-
expect(
203-
resolve('../fixtures/not-found', {
204-
...context,
205-
physicalFilename: testFilePath('not-found.js'),
206-
}),
207-
).toBeUndefined()
252+
expect(
253+
resolve('../fixtures/not-found', {
254+
...context,
255+
physicalFilename: testFilePath('not-found.js'),
256+
}),
257+
).toBeUndefined()
258+
})
259+
260+
it('resolves via a custom resolver with interface version 1 assumed if not specified', () => {
261+
const context = testContext({
262+
'import-x/resolver': './foo-bar-resolver-no-version',
208263
})
209264

210-
it('resolves via a custom resolver with interface version 1 assumed if not specified', () => {
211-
const context = testContext({
212-
'import-x/resolver': './foo-bar-resolver-no-version',
213-
})
265+
expect(resolve('../fixtures/foo', context)).toBe(
266+
testFilePath('./bar.jsx'),
267+
)
214268

215-
expect(resolve('../fixtures/foo', context)).toBe(
216-
testFilePath('./bar.jsx'),
217-
)
269+
expect(
270+
resolve('../fixtures/exception', {
271+
...context,
272+
physicalFilename: testFilePath('exception.js'),
273+
}),
274+
).toBeUndefined()
218275

219-
expect(
220-
resolve('../fixtures/exception', {
221-
...context,
222-
physicalFilename: testFilePath('exception.js'),
223-
}),
224-
).toBeUndefined()
276+
expect(
277+
resolve('../fixtures/not-found', {
278+
...context,
279+
physicalFilename: testFilePath('not-found.js'),
280+
}),
281+
).toBeUndefined()
282+
})
225283

226-
expect(
227-
resolve('../fixtures/not-found', {
228-
...context,
229-
physicalFilename: testFilePath('not-found.js'),
230-
}),
231-
).toBeUndefined()
284+
it('resolves via a custom resolver with interface version 2', () => {
285+
const context = testContext({
286+
'import-x/resolver': './foo-bar-resolver-v2',
232287
})
288+
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
289+
context.report = reportInfo => {
290+
testContextReports.push(reportInfo)
291+
}
233292

234-
it('resolves via a custom resolver with interface version 2', () => {
235-
const context = testContext({
236-
'import-x/resolver': './foo-bar-resolver-v2',
237-
})
238-
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
239-
context.report = reportInfo => {
240-
testContextReports.push(reportInfo)
241-
}
293+
expect(resolve('../fixtures/foo', context)).toBe(
294+
testFilePath('./bar.jsx'),
295+
)
242296

243-
expect(resolve('../fixtures/foo', context)).toBe(
244-
testFilePath('./bar.jsx'),
245-
)
297+
testContextReports.length = 0
298+
expect(
299+
resolve('../fixtures/exception', {
300+
...context,
301+
physicalFilename: testFilePath('exception.js'),
302+
}),
303+
).toBeUndefined()
304+
expect(testContextReports[0]).toBeInstanceOf(Object)
305+
expect(
306+
'message' in testContextReports[0] && testContextReports[0].message,
307+
).toMatch('Resolve error: foo-bar-resolver-v2 resolve test exception')
308+
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
246309

247-
testContextReports.length = 0
248-
expect(
249-
resolve('../fixtures/exception', {
250-
...context,
251-
physicalFilename: testFilePath('exception.js'),
252-
}),
253-
).toBeUndefined()
254-
expect(testContextReports[0]).toBeInstanceOf(Object)
255-
expect(
256-
'message' in testContextReports[0] && testContextReports[0].message,
257-
).toMatch('Resolve error: foo-bar-resolver-v2 resolve test exception')
258-
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
310+
testContextReports.length = 0
311+
expect(
312+
resolve('../fixtures/not-found', {
313+
...context,
314+
physicalFilename: testFilePath('not-found.js'),
315+
}),
316+
).toBeUndefined()
317+
expect(testContextReports.length).toBe(0)
318+
})
259319

260-
testContextReports.length = 0
261-
expect(
262-
resolve('../fixtures/not-found', {
263-
...context,
264-
physicalFilename: testFilePath('not-found.js'),
265-
}),
266-
).toBeUndefined()
267-
expect(testContextReports.length).toBe(0)
320+
it('resolves via a custom resolver with interface version 3', () => {
321+
const context = testContext({
322+
'import-x/resolver-next': [
323+
require('../fixtures/foo-bar-resolver-v3').foobarResolver,
324+
],
268325
})
326+
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
327+
context.report = reportInfo => {
328+
testContextReports.push(reportInfo)
329+
}
269330

270-
it('reports invalid import-x/resolver config', () => {
271-
const context = testContext({
272-
// @ts-expect-error - testing
273-
'import-x/resolver': 123.456,
274-
})
275-
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
276-
context.report = reportInfo => {
277-
testContextReports.push(reportInfo)
278-
}
279-
280-
testContextReports.length = 0
281-
expect(resolve('../fixtures/foo', context)).toBeUndefined()
282-
expect(testContextReports[0]).toBeInstanceOf(Object)
283-
expect(
284-
'message' in testContextReports[0] && testContextReports[0].message,
285-
).toMatch('Resolve error: invalid resolver config')
286-
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
287-
})
331+
expect(resolve('../fixtures/foo', context)).toBe(
332+
testFilePath('./bar.jsx'),
333+
)
288334

289-
it('reports loaded resolver with invalid interface', () => {
290-
const resolverName = './foo-bar-resolver-invalid'
291-
const context = testContext({
292-
'import-x/resolver': resolverName,
293-
})
294-
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
295-
context.report = reportInfo => {
296-
testContextReports.push(reportInfo)
297-
}
298-
expect(resolve('../fixtures/foo', context)).toBeUndefined()
299-
expect(testContextReports[0]).toBeInstanceOf(Object)
300-
expect(
301-
'message' in testContextReports[0] && testContextReports[0].message,
302-
).toMatch(
303-
`Resolve error: ${resolverName} with invalid interface loaded as resolver`,
304-
)
305-
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
335+
testContextReports.length = 0
336+
expect(
337+
resolve('../fixtures/exception', {
338+
...context,
339+
physicalFilename: testFilePath('exception.js'),
340+
}),
341+
).toBeUndefined()
342+
expect(testContextReports[0]).toBeInstanceOf(Object)
343+
expect(
344+
'message' in testContextReports[0] && testContextReports[0].message,
345+
).toMatch('Resolve error: foo-bar-resolver-v3 resolve test exception')
346+
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
347+
348+
testContextReports.length = 0
349+
expect(
350+
resolve('../fixtures/not-found', {
351+
...context,
352+
physicalFilename: testFilePath('not-found.js'),
353+
}),
354+
).toBeUndefined()
355+
expect(testContextReports.length).toBe(0)
356+
})
357+
358+
it('reports invalid import-x/resolver config', () => {
359+
const context = testContext({
360+
// @ts-expect-error - testing
361+
'import-x/resolver': 123.456,
306362
})
363+
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
364+
context.report = reportInfo => {
365+
testContextReports.push(reportInfo)
366+
}
367+
368+
testContextReports.length = 0
369+
expect(resolve('../fixtures/foo', context)).toBeUndefined()
370+
expect(testContextReports[0]).toBeInstanceOf(Object)
371+
expect(
372+
'message' in testContextReports[0] && testContextReports[0].message,
373+
).toMatch('Resolve error: invalid resolver config')
374+
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
375+
})
307376

308-
it('respects import-x/resolve extensions', () => {
309-
const context = testContext({
310-
'import-x/resolve': { extensions: ['.jsx'] },
311-
})
377+
it('reports loaded resolver with invalid interface', () => {
378+
const resolverName = './foo-bar-resolver-invalid'
379+
const context = testContext({
380+
'import-x/resolver': resolverName,
381+
})
382+
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
383+
context.report = reportInfo => {
384+
testContextReports.push(reportInfo)
385+
}
386+
expect(resolve('../fixtures/foo', context)).toBeUndefined()
387+
expect(testContextReports[0]).toBeInstanceOf(Object)
388+
expect(
389+
'message' in testContextReports[0] && testContextReports[0].message,
390+
).toMatch(
391+
`Resolve error: ${resolverName} with invalid interface loaded as resolver`,
392+
)
393+
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
394+
})
312395

313-
expect(resolve('./jsx/MyCoolComponent', context)).toBe(
314-
testFilePath('./jsx/MyCoolComponent.jsx'),
315-
)
396+
it('respects import-x/resolve extensions', () => {
397+
const context = testContext({
398+
'import-x/resolve': { extensions: ['.jsx'] },
316399
})
317400

318-
it('reports load exception in a user resolver', () => {
319-
const context = testContext({
320-
'import-x/resolver': './load-error-resolver',
321-
})
322-
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
323-
context.report = reportInfo => {
324-
testContextReports.push(reportInfo)
325-
}
401+
expect(resolve('./jsx/MyCoolComponent', context)).toBe(
402+
testFilePath('./jsx/MyCoolComponent.jsx'),
403+
)
404+
})
326405

327-
expect(
328-
resolve('../fixtures/exception', {
329-
...context,
330-
physicalFilename: testFilePath('exception.js'),
331-
}),
332-
).toBeUndefined()
333-
expect(testContextReports[0]).toBeInstanceOf(Object)
334-
expect(
335-
'message' in testContextReports[0] && testContextReports[0].message,
336-
).toMatch('Resolve error: SyntaxError: TEST SYNTAX ERROR')
337-
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
406+
it('reports load exception in a user resolver', () => {
407+
const context = testContext({
408+
'import-x/resolver': './load-error-resolver',
338409
})
339-
},
340-
)
410+
const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
411+
context.report = reportInfo => {
412+
testContextReports.push(reportInfo)
413+
}
414+
415+
expect(
416+
resolve('../fixtures/exception', {
417+
...context,
418+
physicalFilename: testFilePath('exception.js'),
419+
}),
420+
).toBeUndefined()
421+
expect(testContextReports[0]).toBeInstanceOf(Object)
422+
expect(
423+
'message' in testContextReports[0] && testContextReports[0].message,
424+
).toMatch('Resolve error: SyntaxError: TEST SYNTAX ERROR')
425+
expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
426+
})
427+
})
341428

342429
const caseDescribe = CASE_SENSITIVE_FS ? describe.skip : describe
343430

@@ -563,10 +650,7 @@ describe('resolve', () => {
563650
).toBe(testFilePath('./bar.jsx'))
564651
})
565652

566-
// context.getPhysicalFilename() is available in ESLint 7.28+
567-
;(semver.satisfies(eslintPkg.version, '>= 7.28')
568-
? describe
569-
: describe.skip)('getPhysicalFilename()', () => {
653+
describe('getPhysicalFilename()', () => {
570654
it('as resolver package name(s)', () => {
571655
expect(
572656
resolve(

0 commit comments

Comments
 (0)
Please sign in to comment.