Skip to content

Commit

Permalink
feat: implement custom resolver interface v3 (#192)
Browse files Browse the repository at this point in the history
  • Loading branch information
SukkaW authored Dec 3, 2024
1 parent bc4de89 commit fbf639b
Show file tree
Hide file tree
Showing 7 changed files with 811 additions and 364 deletions.
184 changes: 184 additions & 0 deletions .changeset/orange-nails-attack.md
Original file line number Diff line number Diff line change
@@ -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, {});
}
}
};
```
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import type {
PluginFlatBaseConfig,
PluginFlatConfig,
} from './types'
import { importXResolverCompat } from './utils'

const rules = {
'no-unresolved': noUnresolved,
Expand Down Expand Up @@ -181,4 +182,5 @@ export = {
configs,
flatConfigs,
rules,
importXResolverCompat,
}
104 changes: 46 additions & 58 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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'

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

// 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'
Expand All @@ -42,63 +82,9 @@ export type ResultFound = {
path: string | null
}

export type ResolvedResult = ResultNotFound | ResultFound

export type ResolverResolve<T = unknown> = (
modulePath: string,
sourceFile: string,
config: T,
) => ResolvedResult

export type ResolverResolveImport<T = unknown> = (
modulePath: string,
sourceFile: string,
config: T,
) => string | undefined

export type Resolver<T = unknown, U = T> = {
interfaceVersion?: 1 | 2
resolve: ResolverResolve<T>
resolveImport: ResolverResolveImport<U>
}

export type ResolverName = LiteralUnion<
'node' | 'typescript' | 'webpack',
string
>

export type ResolverRecord = {
node?: boolean | NodeResolverOptions
typescript?: boolean | TsResolverOptions
webpack?: WebpackResolverOptions
[resolve: string]: unknown
}
export type 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?: {
Expand All @@ -112,7 +98,9 @@ export type ImportSettings = {
internalRegex?: string
parsers?: Record<string, readonly FileExtension[]>
resolve?: NodeResolverOptions
resolver?: ImportResolver
resolver?: LegacyImportResolver
'resolver-legacy'?: LegacyImportResolver
'resolver-next'?: NewResolver[]
}

export type WithPluginName<T extends string | object> = T extends string
Expand Down
Loading

0 comments on commit fbf639b

Please sign in to comment.