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, {}); + } + } +}; +``` diff --git a/src/index.ts b/src/index.ts index 0c19a94fe..5d34ef1b0 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/types.ts b/src/types.ts index eee0cdace..e45e57ae2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,35 @@ 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 { + 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' export type ImportType = ImportType_ | 'object' | 'type' @@ -26,6 +52,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' @@ -42,63 +82,9 @@ export type ResultFound = { path: string | null } -export type ResolvedResult = ResultNotFound | ResultFound - -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 Resolver = LegacyResolver | NewResolver -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 +98,9 @@ export type ImportSettings = { internalRegex?: string parsers?: Record resolve?: NodeResolverOptions - resolver?: ImportResolver + resolver?: LegacyImportResolver + 'resolver-legacy'?: 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 new file mode 100644 index 000000000..15d3418ca --- /dev/null +++ b/src/utils/legacy-resolver-settings.ts @@ -0,0 +1,219 @@ +// Although the new import resolver settings is still `import-x/resolver-next`, but it won't stop us from calling existing ones legacy~ + +import { createRequire } from 'node:module' +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', + 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 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, +) { + 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..08f000cd0 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -1,21 +1,21 @@ import fs from 'node:fs' -import { createRequire } from 'node:module' import path from 'node:path' import stableHash from 'stable-hash' import type { ImportSettings, + LegacyResolver, + NewResolver, PluginSettings, RuleContext, - Resolver, - ImportResolver, - ResolverRecord, - ResolverObject, } from '../types' +import { + normalizeConfigResolvers, + resolveWithLegacyResolver, +} from './legacy-resolver-settings' import { ModuleCache } from './module-cache' -import { pkgDir } from './pkg-dir' export const CASE_SENSITIVE_FS = !fs.existsSync( path.resolve( @@ -24,34 +24,9 @@ export const CASE_SENSITIVE_FS = !fs.existsSync( ), ) -const ERROR_NAME = 'EslintPluginImportResolveError' +export const IMPORT_RESOLVE_ERROR_NAME = 'EslintPluginImportResolveError' -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) -} +export const fileExistsCache = new ModuleCache() // https://stackoverflow.com/a/27382838 export function fileExistsWithCaseSync( @@ -95,6 +70,39 @@ 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' && + resolver.name + ) +} + +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, @@ -125,49 +133,63 @@ function fullResolve( return { found: true, path: cachedPath } } - function withResolver(resolver: Resolver, config: unknown) { - if (resolver.interfaceVersion === 2) { - return resolver.resolve(modulePath, sourceFile, config) - } - - try { - const resolved = resolver.resolveImport(modulePath, sourceFile, config) - if (resolved === undefined) { - return { - found: false, - } + if ( + Object.prototype.hasOwnProperty.call(settings, 'import-x/resolver-next') && + settings['import-x/resolver-next'] + ) { + const configResolvers = settings['import-x/resolver-next'] + + 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 } - return { - found: true, - path: resolved, - } - } catch { - return { - found: false, - } - } - } - const configResolvers = settings['import-x/resolver'] || { - node: settings['import-x/resolve'], - } // backward compatibility - - const resolvers = normalizeConfigResolvers(configResolvers, sourceFile) + const resolved = resolver.resolve(modulePath, sourceFile) + if (!resolved.found) { + continue + } - 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-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 = 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 @@ -183,100 +205,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 +222,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 +237,29 @@ 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 + }, + } +} 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..64bdbb894 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 eslintPluginImportX from 'eslint-plugin-import-x' import { CASE_SENSITIVE_FS, fileExistsWithCaseSync, resolve, } from 'eslint-plugin-import-x/utils' +const { importXResolverCompat } = eslintPluginImportX + describe('resolve', () => { it('throws on bad parameters', () => { expect( @@ -104,6 +105,58 @@ 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('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 @@ -179,165 +232,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 +650,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(