From 9b2b7cd3982d9d1f58f5549982add4c1a88f261c Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 1 Dec 2024 16:04:51 +0800 Subject: [PATCH 1/7] refactor: move all existing resolver to legacy --- src/types.ts | 83 ++++-------- src/utils/legacy-resolver-settings.ts | 185 ++++++++++++++++++++++++++ src/utils/resolve.ts | 135 ++----------------- 3 files changed, 218 insertions(+), 185 deletions(-) create mode 100644 src/utils/legacy-resolver-settings.ts diff --git a/src/types.ts b/src/types.ts index eee0cdace..727b8eba2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,30 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils' import type { ResolveOptions } from 'enhanced-resolve' import type { MinimatchOptions } from 'minimatch' -import type { KebabCase, LiteralUnion } from 'type-fest' +import type { KebabCase } from 'type-fest' import type { ImportType as ImportType_, PluginName } from './utils' +import type { LegacyImportResolver, LegacyResolver } from './utils/legacy-resolver-settings' + +export type { + LegacyResolverName, + LegacyResolverName as ResolverName, + + LegacyImportResolver, + LegacyImportResolver as ImportResolver, + + LegacyResolverResolve, + LegacyResolverResolve as ResolverResolve, + + LegacyResolverResolveImport, + LegacyResolverResolveImport as ResolverResolveImport, + + LegacyResolverRecord, + LegacyResolverRecord as ResolverRecord, + + LegacyResolverObject, + LegacyResolverObject as ResolverObject, +} from './utils/legacy-resolver-settings' export type ImportType = ImportType_ | 'object' | 'type' @@ -42,63 +63,9 @@ export type ResultFound = { path: string | null } -export type ResolvedResult = ResultNotFound | ResultFound +export type Resolver = LegacyResolver -export type ResolverResolve = ( - modulePath: string, - sourceFile: string, - config: T, -) => ResolvedResult - -export type ResolverResolveImport = ( - modulePath: string, - sourceFile: string, - config: T, -) => string | undefined - -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[] +export type ResolvedResult = ResultNotFound | ResultFound export type ImportSettings = { cache?: { @@ -112,7 +79,7 @@ export type ImportSettings = { internalRegex?: string parsers?: Record resolve?: NodeResolverOptions - resolver?: ImportResolver + resolver?: LegacyImportResolver } export type WithPluginName = T extends string diff --git a/src/utils/legacy-resolver-settings.ts b/src/utils/legacy-resolver-settings.ts new file mode 100644 index 000000000..2855fb06f --- /dev/null +++ b/src/utils/legacy-resolver-settings.ts @@ -0,0 +1,185 @@ +// Although the new import resolver settings is still `import-x/resolver-next`, but it won't stop us from calling existing ones legacy~ + +import type { LiteralUnion } from "type-fest" + +import { createRequire } from 'node:module' +import type { NodeResolverOptions, ResolvedResult, TsResolverOptions, WebpackResolverOptions } from "../types" +import path from "path" +import { IMPORT_RESOLVE_ERROR_NAME } from "./resolve" +import { pkgDir } from './pkg-dir' + +export type LegacyResolverName = LiteralUnion< + 'node' | 'typescript' | 'webpack', + string +> + +export type LegacyResolverResolveImport = ( + modulePath: string, + sourceFile: string, + config: T, +) => string | undefined + +export type LegacyResolverResolve = ( + modulePath: string, + sourceFile: string, + config: T, +) => ResolvedResult + +export type LegacyResolver = { + interfaceVersion?: 1 | 2 + resolve: LegacyResolverResolve + resolveImport: LegacyResolverResolveImport +} + +export type LegacyResolverObject = { + // node, typescript, webpack... + name: LegacyResolverName + + // Enabled by default + enable?: boolean + + // Options passed to the resolver + options?: + | NodeResolverOptions + | TsResolverOptions + | WebpackResolverOptions + | unknown + + // Any object satisfied Resolver type + resolver: LegacyResolver +} + +export type LegacyResolverRecord = { + node?: boolean | NodeResolverOptions + typescript?: boolean | TsResolverOptions + webpack?: WebpackResolverOptions + [resolve: string]: unknown +} + +export type LegacyImportResolver = + | LegacyResolverName + | LegacyResolverRecord + | LegacyResolverObject + | LegacyResolverName[] + | LegacyResolverRecord[] + | LegacyResolverObject[]; + +export function normalizeConfigResolvers( + resolvers: LegacyImportResolver, + sourceFile: string, +) { + const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers] + const map = new Map>() + + 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 LegacyResolverObject + + const { name, enable = true, options, resolver } = object + map.set(name, { name, enable, options, resolver }) + } else { + const record = nameOrRecordOrObject as LegacyResolverRecord + + 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 = IMPORT_RESOLVE_ERROR_NAME + throw err + } + } + + return [...map.values()] +} + +function requireResolver(name: string, sourceFile: string) { + // Try to resolve package with conventional name + const resolver = + tryRequire(`eslint-import-resolver-${name}`, sourceFile) || + tryRequire(name, sourceFile) || + tryRequire(path.resolve(getBaseDir(sourceFile), name)) + + if (!resolver) { + const err = new Error(`unable to load resolver "${name}".`) + err.name = IMPORT_RESOLVE_ERROR_NAME + throw err + } + if (!isLegacyResolverValid(resolver)) { + const err = new Error(`${name} with invalid interface loaded as resolver`) + err.name = IMPORT_RESOLVE_ERROR_NAME + throw err + } + + return resolver +} + +function isLegacyResolverValid(resolver: object): resolver is LegacyResolver { + if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) { + return ( + 'resolve' in resolver && + !!resolver.resolve && + typeof resolver.resolve === 'function' + ) + } + return ( + 'resolveImport' in resolver && + !!resolver.resolveImport && + typeof resolver.resolveImport === 'function' + ) +} + +function tryRequire( + target: string, + sourceFile?: string | null, +): undefined | T { + let resolved + try { + // Check if the target exists + if (sourceFile == null) { + resolved = require.resolve(target) + } else { + try { + resolved = createRequire(path.resolve(sourceFile)).resolve(target) + } catch { + resolved = require.resolve(target) + } + } + } catch { + // If the target does not exist then just return undefined + return undefined + } + + // If the target exists then return the loaded module + return require(resolved) +} + + +function getBaseDir(sourceFile: string): string { + return pkgDir(sourceFile) || process.cwd() +} diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 3401e3be9..db624a0b6 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -1,5 +1,4 @@ import fs from 'node:fs' -import { createRequire } from 'node:module' import path from 'node:path' import stableHash from 'stable-hash' @@ -9,13 +8,11 @@ import type { PluginSettings, RuleContext, Resolver, - ImportResolver, - ResolverRecord, - ResolverObject, + ResolvedResult, } from '../types' import { ModuleCache } from './module-cache' -import { pkgDir } from './pkg-dir' +import { normalizeConfigResolvers } from './legacy-resolver-settings' export const CASE_SENSITIVE_FS = !fs.existsSync( path.resolve( @@ -24,34 +21,10 @@ export const CASE_SENSITIVE_FS = !fs.existsSync( ), ) -const ERROR_NAME = 'EslintPluginImportResolveError' +export const IMPORT_RESOLVE_ERROR_NAME = 'EslintPluginImportResolveError' -const fileExistsCache = new ModuleCache() +export const fileExistsCache = new ModuleCache() -function tryRequire( - target: string, - sourceFile?: string | null, -): undefined | T { - let resolved - try { - // Check if the target exists - if (sourceFile == null) { - resolved = require.resolve(target) - } else { - try { - resolved = createRequire(path.resolve(sourceFile)).resolve(target) - } catch { - resolved = require.resolve(target) - } - } - } catch { - // If the target does not exist then just return undefined - return undefined - } - - // If the target exists then return the loaded module - return require(resolved) -} // https://stackoverflow.com/a/27382838 export function fileExistsWithCaseSync( @@ -125,7 +98,7 @@ function fullResolve( return { found: true, path: cachedPath } } - function withResolver(resolver: Resolver, config: unknown) { + function withResolver(resolver: Resolver, config: unknown): ResolvedResult { if (resolver.interfaceVersion === 2) { return resolver.resolve(modulePath, sourceFile, config) } @@ -183,100 +156,6 @@ export function relative( return fullResolve(modulePath, sourceFile, settings).path } -function normalizeConfigResolvers( - resolvers: ImportResolver, - sourceFile: string, -) { - const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers] - const map = new Map>() - - 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.values()] -} - -function getBaseDir(sourceFile: string): string { - return pkgDir(sourceFile) || process.cwd() -} - -function requireResolver(name: string, sourceFile: string) { - // Try to resolve package with conventional name - const resolver = - tryRequire(`eslint-import-resolver-${name}`, sourceFile) || - tryRequire(name, sourceFile) || - tryRequire(path.resolve(getBaseDir(sourceFile), name)) - - if (!resolver) { - const err = new Error(`unable to load resolver "${name}".`) - err.name = ERROR_NAME - throw err - } - if (!isResolverValid(resolver)) { - const err = new Error(`${name} with invalid interface loaded as resolver`) - err.name = ERROR_NAME - throw err - } - - return resolver -} - -function isResolverValid(resolver: object): resolver is Resolver { - if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) { - return ( - 'resolve' in resolver && - !!resolver.resolve && - typeof resolver.resolve === 'function' - ) - } - return ( - 'resolveImport' in resolver && - !!resolver.resolveImport && - typeof resolver.resolveImport === 'function' - ) -} - const erroredContexts = new Set() /** @@ -294,7 +173,7 @@ export function resolve(p: string, context: RuleContext) { // The `err.stack` string starts with `err.name` followed by colon and `err.message`. // We're filtering out the default `err.name` because it adds little value to the message. let errMessage = error.message - if (error.name !== ERROR_NAME && error.stack) { + if (error.name !== IMPORT_RESOLVE_ERROR_NAME && error.stack) { errMessage = error.stack.replace(/^Error: /, '') } context.report({ @@ -309,3 +188,5 @@ export function resolve(p: string, context: RuleContext) { } } } + + From dd6fa6b8a2efbe31fa6baa8eb3a4885cdacf3979 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 1 Dec 2024 18:18:07 +0800 Subject: [PATCH 2/7] feat(resolve): implements `import-x/resolver-next` --- src/types.ts | 19 +++++++- src/utils/legacy-resolver-settings.ts | 23 ++++++++++ src/utils/resolve.ts | 64 +++++++++++---------------- 3 files changed, 68 insertions(+), 38 deletions(-) diff --git a/src/types.ts b/src/types.ts index 727b8eba2..a2e73b6cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ import type { ImportType as ImportType_, PluginName } from './utils' import type { LegacyImportResolver, LegacyResolver } from './utils/legacy-resolver-settings' export type { + LegacyResolver, + LegacyResolverName, LegacyResolverName as ResolverName, @@ -47,6 +49,20 @@ export type TsResolverOptions = { extensions?: string[] } & Omit +// TODO: remove prefix New in the next major version +export type NewResolverResolve = ( + modulePath: string, + sourceFile: string, +) => ResolvedResult + +// TODO: remove prefix New in the next major version +export type NewResolver = { + interfaceVersion: 3, + /** optional name for the resolver, this is used in logs/debug output */ + name?: string, + resolve: NewResolverResolve, +} + export type FileExtension = `.${string}` export type DocStyle = 'jsdoc' | 'tomdoc' @@ -63,7 +79,7 @@ export type ResultFound = { path: string | null } -export type Resolver = LegacyResolver +export type Resolver = LegacyResolver | NewResolver export type ResolvedResult = ResultNotFound | ResultFound @@ -80,6 +96,7 @@ export type ImportSettings = { parsers?: Record resolve?: NodeResolverOptions resolver?: LegacyImportResolver + 'resolver-next'?: NewResolver[] } export type WithPluginName = T extends string diff --git a/src/utils/legacy-resolver-settings.ts b/src/utils/legacy-resolver-settings.ts index 2855fb06f..4243204b0 100644 --- a/src/utils/legacy-resolver-settings.ts +++ b/src/utils/legacy-resolver-settings.ts @@ -64,6 +64,29 @@ export type LegacyImportResolver = | LegacyResolverRecord[] | LegacyResolverObject[]; +export function resolveWithLegacyResolver(resolver: LegacyResolver, config: unknown, modulePath: string, sourceFile: string): ResolvedResult { + if (resolver.interfaceVersion === 2) { + return resolver.resolve(modulePath, sourceFile, config) + } + + try { + const resolved = resolver.resolveImport(modulePath, sourceFile, config) + if (resolved === undefined) { + return { + found: false, + } + } + return { + found: true, + path: resolved, + } + } catch { + return { + found: false, + } + } +} + export function normalizeConfigResolvers( resolvers: LegacyImportResolver, sourceFile: string, diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index db624a0b6..eae019d80 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -8,11 +8,12 @@ import type { PluginSettings, RuleContext, Resolver, + LegacyResolver, ResolvedResult, } from '../types' import { ModuleCache } from './module-cache' -import { normalizeConfigResolvers } from './legacy-resolver-settings' +import { normalizeConfigResolvers, resolveWithLegacyResolver } from './legacy-resolver-settings' export const CASE_SENSITIVE_FS = !fs.existsSync( path.resolve( @@ -98,49 +99,38 @@ function fullResolve( return { found: true, path: cachedPath } } - function withResolver(resolver: Resolver, config: unknown): ResolvedResult { - if (resolver.interfaceVersion === 2) { - return resolver.resolve(modulePath, sourceFile, config) - } + if (Object.prototype.hasOwnProperty.call(settings, 'import-x/resolver-next') && settings['import-x/resolver-next']) { + const configResolvers = settings['import-x/resolver-next'] - try { - const resolved = resolver.resolveImport(modulePath, sourceFile, config) - if (resolved === undefined) { - return { - found: false, - } - } - return { - found: true, - path: resolved, - } - } catch { - return { - found: false, + for (const resolver of configResolvers) { + const resolved = resolver.resolve(modulePath, sourceFile) + if (!resolved.found) { + continue } - } - } - - const configResolvers = settings['import-x/resolver'] || { - node: settings['import-x/resolve'], - } // backward compatibility - const resolvers = normalizeConfigResolvers(configResolvers, sourceFile) - - for (const { enable, options, resolver } of resolvers) { - if (!enable) { - continue + // else, counts + fileExistsCache.set(cacheKey, resolved.path as string | null) + return resolved } + } else { + const configResolvers = settings['import-x/resolver'] || { + node: settings['import-x/resolve'], + } // backward compatibility + + for (const { enable, options, resolver } of normalizeConfigResolvers(configResolvers, sourceFile)) { + if (!enable) { + continue + } - const resolved = withResolver(resolver, options) + const resolved = resolveWithLegacyResolver(resolver, options, modulePath, sourceFile) + if (!resolved.found) { + continue + } - if (!resolved.found) { - continue + // else, counts + fileExistsCache.set(cacheKey, resolved.path as string | null) + return resolved } - - // else, counts - fileExistsCache.set(cacheKey, resolved.path as string | null) - return resolved } // failed From 37277675d0047188043fece2518964ce7bcf3b16 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Mon, 2 Dec 2024 21:29:24 +0800 Subject: [PATCH 3/7] test: test against the new version of resolver design --- src/types.ts | 24 +- src/utils/legacy-resolver-settings.ts | 35 ++- src/utils/resolve.ts | 83 ++++-- test/fixtures/foo-bar-resolver-v3.js | 16 ++ test/utils/resolve.spec.ts | 347 +++++++++++++++----------- 5 files changed, 328 insertions(+), 177 deletions(-) create mode 100644 test/fixtures/foo-bar-resolver-v3.js diff --git a/src/types.ts b/src/types.ts index a2e73b6cf..e45e57ae2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,26 +4,29 @@ import type { MinimatchOptions } from 'minimatch' import type { KebabCase } from 'type-fest' import type { ImportType as ImportType_, PluginName } from './utils' -import type { LegacyImportResolver, LegacyResolver } from './utils/legacy-resolver-settings' +import type { + LegacyImportResolver, + LegacyResolver, +} from './utils/legacy-resolver-settings' export type { LegacyResolver, - + // ResolverName LegacyResolverName, LegacyResolverName as ResolverName, - + // ImportResolver LegacyImportResolver, LegacyImportResolver as ImportResolver, - + // ResolverResolve LegacyResolverResolve, LegacyResolverResolve as ResolverResolve, - + // ResolverResolveImport LegacyResolverResolveImport, LegacyResolverResolveImport as ResolverResolveImport, - + // ResolverRecord LegacyResolverRecord, LegacyResolverRecord as ResolverRecord, - + // ResolverObject LegacyResolverObject, LegacyResolverObject as ResolverObject, } from './utils/legacy-resolver-settings' @@ -57,10 +60,10 @@ export type NewResolverResolve = ( // TODO: remove prefix New in the next major version export type NewResolver = { - interfaceVersion: 3, + interfaceVersion: 3 /** optional name for the resolver, this is used in logs/debug output */ - name?: string, - resolve: NewResolverResolve, + name?: string + resolve: NewResolverResolve } export type FileExtension = `.${string}` @@ -96,6 +99,7 @@ export type ImportSettings = { parsers?: Record resolve?: NodeResolverOptions resolver?: LegacyImportResolver + 'resolver-legacy'?: LegacyImportResolver 'resolver-next'?: NewResolver[] } diff --git a/src/utils/legacy-resolver-settings.ts b/src/utils/legacy-resolver-settings.ts index 4243204b0..15d3418ca 100644 --- a/src/utils/legacy-resolver-settings.ts +++ b/src/utils/legacy-resolver-settings.ts @@ -1,12 +1,19 @@ // Although the new import resolver settings is still `import-x/resolver-next`, but it won't stop us from calling existing ones legacy~ -import type { LiteralUnion } from "type-fest" - import { createRequire } from 'node:module' -import type { NodeResolverOptions, ResolvedResult, TsResolverOptions, WebpackResolverOptions } from "../types" -import path from "path" -import { IMPORT_RESOLVE_ERROR_NAME } from "./resolve" +import path from 'node:path' + +import type { LiteralUnion } from 'type-fest' + +import type { + NodeResolverOptions, + ResolvedResult, + TsResolverOptions, + WebpackResolverOptions, +} from '../types' + import { pkgDir } from './pkg-dir' +import { IMPORT_RESOLVE_ERROR_NAME } from './resolve' export type LegacyResolverName = LiteralUnion< 'node' | 'typescript' | 'webpack', @@ -40,10 +47,10 @@ export type LegacyResolverObject = { // Options passed to the resolver options?: - | NodeResolverOptions - | TsResolverOptions - | WebpackResolverOptions - | unknown + | NodeResolverOptions + | TsResolverOptions + | WebpackResolverOptions + | unknown // Any object satisfied Resolver type resolver: LegacyResolver @@ -62,9 +69,14 @@ export type LegacyImportResolver = | LegacyResolverObject | LegacyResolverName[] | LegacyResolverRecord[] - | LegacyResolverObject[]; + | LegacyResolverObject[] -export function resolveWithLegacyResolver(resolver: LegacyResolver, config: unknown, modulePath: string, sourceFile: string): ResolvedResult { +export function resolveWithLegacyResolver( + resolver: LegacyResolver, + config: unknown, + modulePath: string, + sourceFile: string, +): ResolvedResult { if (resolver.interfaceVersion === 2) { return resolver.resolve(modulePath, sourceFile, config) } @@ -202,7 +214,6 @@ function tryRequire( return require(resolved) } - function getBaseDir(sourceFile: string): string { return pkgDir(sourceFile) || process.cwd() } diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index eae019d80..d9096ec75 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -5,15 +5,16 @@ import stableHash from 'stable-hash' import type { ImportSettings, + NewResolver, PluginSettings, RuleContext, - Resolver, - LegacyResolver, - ResolvedResult, } from '../types' +import { + normalizeConfigResolvers, + resolveWithLegacyResolver, +} from './legacy-resolver-settings' import { ModuleCache } from './module-cache' -import { normalizeConfigResolvers, resolveWithLegacyResolver } from './legacy-resolver-settings' export const CASE_SENSITIVE_FS = !fs.existsSync( path.resolve( @@ -26,7 +27,6 @@ export const IMPORT_RESOLVE_ERROR_NAME = 'EslintPluginImportResolveError' export const fileExistsCache = new ModuleCache() - // https://stackoverflow.com/a/27382838 export function fileExistsWithCaseSync( filepath: string | null, @@ -69,6 +69,38 @@ export function fileExistsWithCaseSync( let prevSettings: PluginSettings | null = null let memoizedHash: string +function isNamedResolver(resolver: unknown): resolver is { name: string } { + return !!( + typeof resolver === 'object' && + resolver && + 'name' in resolver && + typeof resolver.name === 'string' + ) +} + +function isValidNewResolver(resolver: unknown): resolver is NewResolver { + if (typeof resolver !== 'object' || resolver == null) { + return false + } + + if (!('resolve' in resolver) || !('interfaceVersion' in resolver)) { + return false + } + + if ( + typeof resolver.interfaceVersion !== 'number' || + resolver.interfaceVersion !== 3 + ) { + return false + } + + if (typeof resolver.resolve !== 'function') { + return false + } + + return true +} + function fullResolve( modulePath: string, sourceFile: string, @@ -99,10 +131,24 @@ function fullResolve( return { found: true, path: cachedPath } } - if (Object.prototype.hasOwnProperty.call(settings, 'import-x/resolver-next') && settings['import-x/resolver-next']) { + if ( + Object.prototype.hasOwnProperty.call(settings, 'import-x/resolver-next') && + settings['import-x/resolver-next'] + ) { const configResolvers = settings['import-x/resolver-next'] - for (const resolver of configResolvers) { + for (let i = 0, len = configResolvers.length; i < len; i++) { + const resolver = configResolvers[i] + const resolverName = isNamedResolver(resolver) + ? resolver.name + : `settings['import-x/resolver-next'][${i}]` + + if (!isValidNewResolver(resolver)) { + const err = new TypeError(`${resolverName} is not a valid import resolver for eslint-plugin-import-x!`) + err.name = IMPORT_RESOLVE_ERROR_NAME + throw err + } + const resolved = resolver.resolve(modulePath, sourceFile) if (!resolved.found) { continue @@ -113,16 +159,25 @@ function fullResolve( return resolved } } else { - const configResolvers = settings['import-x/resolver'] || { - node: settings['import-x/resolve'], - } // backward compatibility - - for (const { enable, options, resolver } of normalizeConfigResolvers(configResolvers, sourceFile)) { + const configResolvers = settings['import-x/resolver-legacy'] || + settings['import-x/resolver'] || { + node: settings['import-x/resolve'], + } // backward compatibility + + for (const { enable, options, resolver } of normalizeConfigResolvers( + configResolvers, + sourceFile, + )) { if (!enable) { continue } - const resolved = resolveWithLegacyResolver(resolver, options, modulePath, sourceFile) + const resolved = resolveWithLegacyResolver( + resolver, + options, + modulePath, + sourceFile, + ) if (!resolved.found) { continue } @@ -178,5 +233,3 @@ export function resolve(p: string, context: RuleContext) { } } } - - diff --git a/test/fixtures/foo-bar-resolver-v3.js b/test/fixtures/foo-bar-resolver-v3.js new file mode 100644 index 000000000..24c0344a1 --- /dev/null +++ b/test/fixtures/foo-bar-resolver-v3.js @@ -0,0 +1,16 @@ +var path = require('path') + +exports.foobarResolver = (/** @type {import('eslint-plugin-import-x/types').NewResolver} */ { + name: 'resolver-foo-bar', + interfaceVersion: 3, + resolve: function (modulePath, sourceFile) { + var sourceFileName = path.basename(sourceFile) + if (sourceFileName === 'foo.js') { + return { found: true, path: path.join(__dirname, 'bar.jsx') } + } + if (sourceFileName === 'exception.js') { + throw new Error('foo-bar-resolver-v3 resolve test exception') + } + return { found: false } + } +}) diff --git a/test/utils/resolve.spec.ts b/test/utils/resolve.spec.ts index 2bc7a63ff..f82211062 100644 --- a/test/utils/resolve.spec.ts +++ b/test/utils/resolve.spec.ts @@ -104,6 +104,42 @@ describe('resolve', () => { expect(testContextReports.length).toBe(0) }) + it('resolves via a custom resolver with interface version 3', () => { + const context = testContext({ + 'import-x/resolver-next': [ + require('../fixtures/foo-bar-resolver-v3').foobarResolver, + ], + }) + const testContextReports: Array> = [] + context.report = reportInfo => { + testContextReports.push(reportInfo) + } + + expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx')) + + testContextReports.length = 0 + expect( + resolve('../fixtures/exception', { + ...context, + physicalFilename: testFilePath('exception.js'), + }), + ).toBeUndefined() + expect(testContextReports[0]).toBeInstanceOf(Object) + expect( + 'message' in testContextReports[0] && testContextReports[0].message, + ).toMatch('Resolve error: foo-bar-resolver-v3 resolve test exception') + expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + + testContextReports.length = 0 + expect( + resolve('../fixtures/not-found', { + ...context, + physicalFilename: testFilePath('not-found.js'), + }), + ).toBeUndefined() + expect(testContextReports.length).toBe(0) + }) + it('reports invalid import-x/resolver config', () => { const context = testContext({ // @ts-expect-error - testing @@ -179,165 +215,199 @@ describe('resolve', () => { expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) }) - // context.getPhysicalFilename() is available in ESLint 7.28+ - ;(semver.satisfies(eslintPkg.version, '>= 7.28') ? describe : describe.skip)( - 'getPhysicalFilename()', - () => { - it('resolves via a custom resolver with interface version 1', () => { - const context = testContext({ - 'import-x/resolver': './foo-bar-resolver-v1', - }) + describe('getPhysicalFilename()', () => { + it('resolves via a custom resolver with interface version 1', () => { + const context = testContext({ + 'import-x/resolver': './foo-bar-resolver-v1', + }) - expect(resolve('../fixtures/foo', context)).toBe( - testFilePath('./bar.jsx'), - ) + expect(resolve('../fixtures/foo', context)).toBe( + testFilePath('./bar.jsx'), + ) - expect( - resolve('../fixtures/exception', { - ...context, - physicalFilename: testFilePath('exception.js'), - }), - ).toBeUndefined() + expect( + resolve('../fixtures/exception', { + ...context, + physicalFilename: testFilePath('exception.js'), + }), + ).toBeUndefined() - expect( - resolve('../fixtures/not-found', { - ...context, - physicalFilename: testFilePath('not-found.js'), - }), - ).toBeUndefined() + expect( + resolve('../fixtures/not-found', { + ...context, + physicalFilename: testFilePath('not-found.js'), + }), + ).toBeUndefined() + }) + + it('resolves via a custom resolver with interface version 1 assumed if not specified', () => { + const context = testContext({ + 'import-x/resolver': './foo-bar-resolver-no-version', }) - it('resolves via a custom resolver with interface version 1 assumed if not specified', () => { - const context = testContext({ - 'import-x/resolver': './foo-bar-resolver-no-version', - }) + expect(resolve('../fixtures/foo', context)).toBe( + testFilePath('./bar.jsx'), + ) - expect(resolve('../fixtures/foo', context)).toBe( - testFilePath('./bar.jsx'), - ) + expect( + resolve('../fixtures/exception', { + ...context, + physicalFilename: testFilePath('exception.js'), + }), + ).toBeUndefined() - expect( - resolve('../fixtures/exception', { - ...context, - physicalFilename: testFilePath('exception.js'), - }), - ).toBeUndefined() + expect( + resolve('../fixtures/not-found', { + ...context, + physicalFilename: testFilePath('not-found.js'), + }), + ).toBeUndefined() + }) - expect( - resolve('../fixtures/not-found', { - ...context, - physicalFilename: testFilePath('not-found.js'), - }), - ).toBeUndefined() + it('resolves via a custom resolver with interface version 2', () => { + const context = testContext({ + 'import-x/resolver': './foo-bar-resolver-v2', }) + const testContextReports: Array> = [] + context.report = reportInfo => { + testContextReports.push(reportInfo) + } - it('resolves via a custom resolver with interface version 2', () => { - const context = testContext({ - 'import-x/resolver': './foo-bar-resolver-v2', - }) - const testContextReports: Array> = [] - context.report = reportInfo => { - testContextReports.push(reportInfo) - } + expect(resolve('../fixtures/foo', context)).toBe( + testFilePath('./bar.jsx'), + ) - expect(resolve('../fixtures/foo', context)).toBe( - testFilePath('./bar.jsx'), - ) + testContextReports.length = 0 + expect( + resolve('../fixtures/exception', { + ...context, + physicalFilename: testFilePath('exception.js'), + }), + ).toBeUndefined() + expect(testContextReports[0]).toBeInstanceOf(Object) + expect( + 'message' in testContextReports[0] && testContextReports[0].message, + ).toMatch('Resolve error: foo-bar-resolver-v2 resolve test exception') + expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) - testContextReports.length = 0 - expect( - resolve('../fixtures/exception', { - ...context, - physicalFilename: testFilePath('exception.js'), - }), - ).toBeUndefined() - expect(testContextReports[0]).toBeInstanceOf(Object) - expect( - 'message' in testContextReports[0] && testContextReports[0].message, - ).toMatch('Resolve error: foo-bar-resolver-v2 resolve test exception') - expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + testContextReports.length = 0 + expect( + resolve('../fixtures/not-found', { + ...context, + physicalFilename: testFilePath('not-found.js'), + }), + ).toBeUndefined() + expect(testContextReports.length).toBe(0) + }) - testContextReports.length = 0 - expect( - resolve('../fixtures/not-found', { - ...context, - physicalFilename: testFilePath('not-found.js'), - }), - ).toBeUndefined() - expect(testContextReports.length).toBe(0) + it('resolves via a custom resolver with interface version 3', () => { + const context = testContext({ + 'import-x/resolver-next': [ + require('../fixtures/foo-bar-resolver-v3').foobarResolver, + ], }) + const testContextReports: Array> = [] + context.report = reportInfo => { + testContextReports.push(reportInfo) + } - it('reports invalid import-x/resolver config', () => { - const context = testContext({ - // @ts-expect-error - testing - 'import-x/resolver': 123.456, - }) - const testContextReports: Array> = [] - context.report = reportInfo => { - testContextReports.push(reportInfo) - } - - testContextReports.length = 0 - expect(resolve('../fixtures/foo', context)).toBeUndefined() - expect(testContextReports[0]).toBeInstanceOf(Object) - expect( - 'message' in testContextReports[0] && testContextReports[0].message, - ).toMatch('Resolve error: invalid resolver config') - expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) - }) + expect(resolve('../fixtures/foo', context)).toBe( + testFilePath('./bar.jsx'), + ) - it('reports loaded resolver with invalid interface', () => { - const resolverName = './foo-bar-resolver-invalid' - const context = testContext({ - 'import-x/resolver': resolverName, - }) - const testContextReports: Array> = [] - context.report = reportInfo => { - testContextReports.push(reportInfo) - } - expect(resolve('../fixtures/foo', context)).toBeUndefined() - expect(testContextReports[0]).toBeInstanceOf(Object) - expect( - 'message' in testContextReports[0] && testContextReports[0].message, - ).toMatch( - `Resolve error: ${resolverName} with invalid interface loaded as resolver`, - ) - expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + testContextReports.length = 0 + expect( + resolve('../fixtures/exception', { + ...context, + physicalFilename: testFilePath('exception.js'), + }), + ).toBeUndefined() + expect(testContextReports[0]).toBeInstanceOf(Object) + expect( + 'message' in testContextReports[0] && testContextReports[0].message, + ).toMatch('Resolve error: foo-bar-resolver-v3 resolve test exception') + expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + + testContextReports.length = 0 + expect( + resolve('../fixtures/not-found', { + ...context, + physicalFilename: testFilePath('not-found.js'), + }), + ).toBeUndefined() + expect(testContextReports.length).toBe(0) + }) + + it('reports invalid import-x/resolver config', () => { + const context = testContext({ + // @ts-expect-error - testing + 'import-x/resolver': 123.456, }) + const testContextReports: Array> = [] + context.report = reportInfo => { + testContextReports.push(reportInfo) + } + + testContextReports.length = 0 + expect(resolve('../fixtures/foo', context)).toBeUndefined() + expect(testContextReports[0]).toBeInstanceOf(Object) + expect( + 'message' in testContextReports[0] && testContextReports[0].message, + ).toMatch('Resolve error: invalid resolver config') + expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + }) - it('respects import-x/resolve extensions', () => { - const context = testContext({ - 'import-x/resolve': { extensions: ['.jsx'] }, - }) + it('reports loaded resolver with invalid interface', () => { + const resolverName = './foo-bar-resolver-invalid' + const context = testContext({ + 'import-x/resolver': resolverName, + }) + const testContextReports: Array> = [] + context.report = reportInfo => { + testContextReports.push(reportInfo) + } + expect(resolve('../fixtures/foo', context)).toBeUndefined() + expect(testContextReports[0]).toBeInstanceOf(Object) + expect( + 'message' in testContextReports[0] && testContextReports[0].message, + ).toMatch( + `Resolve error: ${resolverName} with invalid interface loaded as resolver`, + ) + expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + }) - expect(resolve('./jsx/MyCoolComponent', context)).toBe( - testFilePath('./jsx/MyCoolComponent.jsx'), - ) + it('respects import-x/resolve extensions', () => { + const context = testContext({ + 'import-x/resolve': { extensions: ['.jsx'] }, }) - it('reports load exception in a user resolver', () => { - const context = testContext({ - 'import-x/resolver': './load-error-resolver', - }) - const testContextReports: Array> = [] - context.report = reportInfo => { - testContextReports.push(reportInfo) - } + expect(resolve('./jsx/MyCoolComponent', context)).toBe( + testFilePath('./jsx/MyCoolComponent.jsx'), + ) + }) - expect( - resolve('../fixtures/exception', { - ...context, - physicalFilename: testFilePath('exception.js'), - }), - ).toBeUndefined() - expect(testContextReports[0]).toBeInstanceOf(Object) - expect( - 'message' in testContextReports[0] && testContextReports[0].message, - ).toMatch('Resolve error: SyntaxError: TEST SYNTAX ERROR') - expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + it('reports load exception in a user resolver', () => { + const context = testContext({ + 'import-x/resolver': './load-error-resolver', }) - }, - ) + const testContextReports: Array> = [] + context.report = reportInfo => { + testContextReports.push(reportInfo) + } + + expect( + resolve('../fixtures/exception', { + ...context, + physicalFilename: testFilePath('exception.js'), + }), + ).toBeUndefined() + expect(testContextReports[0]).toBeInstanceOf(Object) + expect( + 'message' in testContextReports[0] && testContextReports[0].message, + ).toMatch('Resolve error: SyntaxError: TEST SYNTAX ERROR') + expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 }) + }) + }) const caseDescribe = CASE_SENSITIVE_FS ? describe.skip : describe @@ -563,10 +633,7 @@ describe('resolve', () => { ).toBe(testFilePath('./bar.jsx')) }) - // context.getPhysicalFilename() is available in ESLint 7.28+ - ;(semver.satisfies(eslintPkg.version, '>= 7.28') - ? describe - : describe.skip)('getPhysicalFilename()', () => { + describe('getPhysicalFilename()', () => { it('as resolver package name(s)', () => { expect( resolve( From adb8bd768f601326d8579bce2ada8fd735076411 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Mon, 2 Dec 2024 22:39:13 +0800 Subject: [PATCH 4/7] feat(util): `importXResolverCompat` --- src/index.ts | 2 ++ src/utils/resolve.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 0c19a94fe..ff9245d8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ import type { PluginFlatBaseConfig, PluginFlatConfig, } from './types' +import { importXResolverCompat } from './utils' const rules = { 'no-unresolved': noUnresolved, @@ -181,4 +182,5 @@ export = { configs, flatConfigs, rules, + importXResolverCompat } diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index d9096ec75..6ce9b06c8 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -5,6 +5,7 @@ import stableHash from 'stable-hash' import type { ImportSettings, + LegacyResolver, NewResolver, PluginSettings, RuleContext, @@ -74,7 +75,8 @@ function isNamedResolver(resolver: unknown): resolver is { name: string } { typeof resolver === 'object' && resolver && 'name' in resolver && - typeof resolver.name === 'string' + typeof resolver.name === 'string' && + resolver.name ) } @@ -233,3 +235,26 @@ export function resolve(p: string, context: RuleContext) { } } } + +export function importXResolverCompat(resolver: LegacyResolver | NewResolver, resolverOptions: unknown = {}): NewResolver { + // Somehow the resolver is already using v3 interface + if (isValidNewResolver(resolver)) { + return resolver + } + + return { + // deliberately not providing the name, because we can't get the name from legacy resolvers + // By omitting the name, the log will use identifiable name like `settings['import-x/resolver-next'][0]` + // name: 'import-x-resolver-compat', + interfaceVersion: 3, + resolve: (modulePath, sourceFile) => { + const resolved = resolveWithLegacyResolver( + resolver, + resolverOptions, + modulePath, + sourceFile, + ) + return resolved + }, + } +} From 3b022734b8ae18a70c07af5c578b8c3a919b621b Mon Sep 17 00:00:00 2001 From: SukkaW Date: Mon, 2 Dec 2024 22:47:34 +0800 Subject: [PATCH 5/7] test: `importXResolverCompat` --- test/utils/resolve.spec.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/utils/resolve.spec.ts b/test/utils/resolve.spec.ts index f82211062..67d00208a 100644 --- a/test/utils/resolve.spec.ts +++ b/test/utils/resolve.spec.ts @@ -3,17 +3,18 @@ import path from 'node:path' import { setTimeout } from 'node:timers/promises' import type { TSESLint } from '@typescript-eslint/utils' -import eslintPkg from 'eslint/package.json' -import semver from 'semver' import { testContext, testFilePath } from '../utils' import { CASE_SENSITIVE_FS, fileExistsWithCaseSync, - resolve, + resolve } from 'eslint-plugin-import-x/utils' +import eslintPluginImportX from 'eslint-plugin-import-x' +const { importXResolverCompat } = eslintPluginImportX; + describe('resolve', () => { it('throws on bad parameters', () => { expect( @@ -140,6 +141,22 @@ describe('resolve', () => { expect(testContextReports.length).toBe(0) }) + it('importXResolverCompat()', () => { + let context = testContext({ + 'import-x/resolver-next': [ + importXResolverCompat(require('../fixtures/foo-bar-resolver-v2')), + ], + }) + expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx')) + + context = testContext({ + 'import-x/resolver-next': [ + importXResolverCompat(require('../fixtures/foo-bar-resolver-v1')), + ], + }) + expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx')) + }) + it('reports invalid import-x/resolver config', () => { const context = testContext({ // @ts-expect-error - testing From 1ba821de499ee340d3175c59e635a5b32002e208 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Mon, 2 Dec 2024 22:48:07 +0800 Subject: [PATCH 6/7] chore: make eslint happy --- src/index.ts | 2 +- src/utils/resolve.ts | 9 +++++++-- test/utils/resolve.spec.ts | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index ff9245d8a..5d34ef1b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -182,5 +182,5 @@ export = { configs, flatConfigs, rules, - importXResolverCompat + importXResolverCompat, } diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 6ce9b06c8..08f000cd0 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -146,7 +146,9 @@ function fullResolve( : `settings['import-x/resolver-next'][${i}]` if (!isValidNewResolver(resolver)) { - const err = new TypeError(`${resolverName} is not a valid import resolver for eslint-plugin-import-x!`) + const err = new TypeError( + `${resolverName} is not a valid import resolver for eslint-plugin-import-x!`, + ) err.name = IMPORT_RESOLVE_ERROR_NAME throw err } @@ -236,7 +238,10 @@ export function resolve(p: string, context: RuleContext) { } } -export function importXResolverCompat(resolver: LegacyResolver | NewResolver, resolverOptions: unknown = {}): NewResolver { +export function importXResolverCompat( + resolver: LegacyResolver | NewResolver, + resolverOptions: unknown = {}, +): NewResolver { // Somehow the resolver is already using v3 interface if (isValidNewResolver(resolver)) { return resolver diff --git a/test/utils/resolve.spec.ts b/test/utils/resolve.spec.ts index 67d00208a..64bdbb894 100644 --- a/test/utils/resolve.spec.ts +++ b/test/utils/resolve.spec.ts @@ -6,14 +6,14 @@ import type { TSESLint } from '@typescript-eslint/utils' import { testContext, testFilePath } from '../utils' +import eslintPluginImportX from 'eslint-plugin-import-x' import { CASE_SENSITIVE_FS, fileExistsWithCaseSync, - resolve + resolve, } from 'eslint-plugin-import-x/utils' -import eslintPluginImportX from 'eslint-plugin-import-x' -const { importXResolverCompat } = eslintPluginImportX; +const { importXResolverCompat } = eslintPluginImportX describe('resolve', () => { it('throws on bad parameters', () => { From e03e3dee8630bf233cb402621b9536ae10257587 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Mon, 2 Dec 2024 22:53:09 +0800 Subject: [PATCH 7/7] chore: add changeset --- .changeset/orange-nails-attack.md | 184 ++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 .changeset/orange-nails-attack.md diff --git a/.changeset/orange-nails-attack.md b/.changeset/orange-nails-attack.md new file mode 100644 index 000000000..70d38b085 --- /dev/null +++ b/.changeset/orange-nails-attack.md @@ -0,0 +1,184 @@ +The PR implements the new resolver design proposed in https://github.com/un-ts/eslint-plugin-import-x/issues/40#issuecomment-2381444266 + +---- + +### For `eslint-plugin-import-x` users + +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`: + +```js +// eslint.config.js +import { createTsResolver } from '#custom-resolver'; +const { createOxcResolver } = require('path/to/a/custom/resolver'); + +const nodeResolverObject = { + interfaceVersion: 3, + name: 'my-custom-eslint-import-resolver', + resolve(modPath, sourcePath) { + }; +}; + +module.exports = { + settings: { + // multiple resolvers + 'import-x/resolver-next': [ + nodeResolverObject, + createTsResolver(enhancedResolverOptions), + createOxcResolver(oxcOptions), + ], + // single resolver: + 'import-x/resolver-next': [createOxcResolver(oxcOptions)] + } +} +``` + +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`. + +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`. + +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`: + +```js +// An example of the current `import-x/resolver` settings +module.exports = { + settings: { + 'import-x/resolver': { + node: nodeResolverOpt + webpack: webpackResolverOpt, + 'custom-resolver': customResolverOpt + } + } +} + +// When migrating to `import-x/resolver-next`, you CAN'T use legacy versions of resolvers directly: +module.exports = { + settings: { + // THIS WON'T WORK, the resolver interface required for `import-x/resolver-next` is different. + 'import-x/resolver-next': [ + require('eslint-import-resolver-node'), + require('eslint-import-resolver-webpack'), + require('some-custom-resolver') + ]; + } +} +``` + +For easier migration, the PR also introduces a compat utility `importXResolverCompat` that you can use in your `eslint.config.js`: + +```js +// eslint.config.js +import eslintPluginImportX, { importXResolverCompat } from 'eslint-plugin-import-x'; +// or +const eslintPluginImportX = require('eslint-plugin-import-x'); +const { importXResolverCompat } = eslintPluginImportX; + +module.exports = { + settings: { + // THIS WILL WORK as you have wrapped the previous version of resolvers with the `importXResolverCompat` + 'import-x/resolver-next': [ + importXResolverCompat(require('eslint-import-resolver-node'), nodeResolveOptions), + importXResolverCompat(require('eslint-import-resolver-webpack'), webpackResolveOptions), + importXResolverCompat(require('some-custom-resolver'), {}) + ]; + } +} +``` + +### For custom import resolver developers + +This is the new API design of the resolver interface: + +```ts +export interface NewResolver { + interfaceVersion: 3, + name?: string, // This will be included in the debug log + resolve: (modulePath: string, sourceFile: string) => ResolvedResult +} + +// The `ResultNotFound` (returned when not resolved) is the same, no changes +export interface ResultNotFound { + found: false + path?: undefined +} + +// The `ResultFound` (returned resolve result) is also the same, no changes +export interface ResultFound { + found: true + path: string | null +} + +export type ResolvedResult = ResultNotFound | ResultFound +``` + +You will be able to import `NewResolver` from `eslint-plugin-import-x/types`. + +The most notable change is that `eslint-plugin-import-x` no longer passes the third argument (`options`) to the `resolve` function. + +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: + +```js +// custom-resolver.js +exports.createCustomResolver = (options) => { + // The options are consumed outside the `resolve` function. + const resolverInstance = new ResolverFactory(options); + + return { + name: 'custom-resolver', + interfaceVersion: 3, + resolve(mod, source) { + const found = resolverInstance.resolve(mod, {}); + + // Of course, you still have access to the `options` variable here inside + // the `resolve` function. That's the power of JavaScript Closures~ + } + } +}; + +// eslint.config.js +const { createCustomResolver } = require('custom-resolver') + +module.exports = { + settings: { + 'import-x/resolver-next': [ + createCustomResolver(options) + ]; + } +} +``` + +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: + +```js +module.exports = { + interfaceVersion: 2, + resolve(mod, source) { + // every time the `resolve` function is called, a new instance is created + // This is very slow + const resolverInstance = ResolverFactory.createResolver({}); + const found = resolverInstance.resolve(mod, {}); + } +} +``` + +With the factory function pattern, you can create a resolver instance beforehand: + +```js +exports.createCustomResolver = (options) => { + // `enhance-resolve` allows you to create a reusable instance: + const resolverInstance = ResolverFactory.createResolver({}); + const resolverInstance = enhanceResolve.create({}); + + // `oxc-resolver` also allows you to create a reusable instance: + const resolverInstance = new ResolverFactory({}); + + return { + name: 'custom-resolver', + interfaceVersion: 3, + resolve(mod, source) { + // the same re-usable instance is shared across `resolve` invocations. + // more performant + const found = resolverInstance.resolve(mod, {}); + } + } +}; +```