Skip to content

Commit 90c1cd0

Browse files
JounQinXunnamiusstephenjason89autofix-ci[bot]
authored
feat(extensions): support pathGroupOverrides and fix options (#327)
Co-authored-by: "Xunnamius (Romulus)" <[email protected]> Co-authored-by: Stephen Jason Wang <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent de7bae3 commit 90c1cd0

File tree

10 files changed

+429
-25
lines changed

10 files changed

+429
-25
lines changed

.changeset/fast-bees-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-import-x": minor
3+
---
4+
5+
feat(extensions): support `pathGroupOverrides` and `fix` options

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ jobs:
2121
- 18
2222
- 20
2323
- 22
24+
- 24
2425
eslint:
2526
- 8.56
2627
- 8
2728
- 9
2829

2930
include:
3031
- executeLint: true
31-
node: 20
32+
node: 22
33+
eslint: 9
3234
os: ubuntu-latest
3335
fail-fast: false
3436

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export default [
259259
| [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | |
260260
| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | |
261261
| [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | |
262-
| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | | | |
262+
| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | 🔧 | 💡 | |
263263
| [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | |
264264
| [group-exports](docs/rules/group-exports.md) | Prefer named exports to be grouped together in a single export declaration. | | | | | | |
265265
| [imports-first](docs/rules/imports-first.md) | Replaced by `import-x/first`. | | | | 🔧 | | ❌ |
@@ -700,7 +700,6 @@ Detailed changes for each release are documented in [CHANGELOG.md](./CHANGELOG.m
700700
[`eslint_d`]: https://www.npmjs.com/package/eslint_d
701701
[`eslint-loader`]: https://www.npmjs.com/package/eslint-loader
702702
[`get-tsconfig`]: https://github.com/privatenumber/get-tsconfig
703-
[`napi-rs`]: https://github.com/napi-rs/napi-rs
704703
[`tsconfig-paths`]: https://github.com/dividab/tsconfig-paths
705704
[`typescript`]: https://github.com/microsoft/TypeScript
706705
[`unrs-resolver`]: https://github.com/unrs/unrs-resolver

docs/rules/extensions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# import-x/extensions
22

3+
🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
4+
35
<!-- end auto-generated rule header -->
46

57
Some file resolve algorithms allow you to omit the file extension within the import source path. For example the `node` resolver (which does not yet support ESM/`import`) can resolve `./foo/bar` to the absolute path `/User/someone/foo/bar.js` because the `.js` extension is resolved automatically by default in CJS. Depending on the resolver you can configure more extensions to get resolved automatically.

src/rules/extensions.ts

Lines changed: 139 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import path from 'node:path'
22

3+
import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
4+
import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint'
5+
import { minimatch } from 'minimatch'
6+
import type { MinimatchOptions } from 'minimatch'
7+
38
import type { FileExtension, RuleContext } from '../types.js'
49
import {
510
isBuiltIn,
@@ -8,32 +13,53 @@ import {
813
createRule,
914
moduleVisitor,
1015
resolve,
16+
parsePath,
17+
stringifyPath,
1118
} from '../utils/index.js'
1219

1320
const modifierValues = ['always', 'ignorePackages', 'never'] as const
1421

1522
const modifierSchema = {
16-
type: 'string' as const,
23+
type: 'string',
1724
enum: [...modifierValues],
18-
}
25+
} satisfies JSONSchema4
1926

2027
const modifierByFileExtensionSchema = {
21-
type: 'object' as const,
28+
type: 'object',
2229
patternProperties: { '.*': modifierSchema },
23-
}
30+
} satisfies JSONSchema4
2431

2532
const properties = {
26-
type: 'object' as const,
33+
type: 'object',
2734
properties: {
2835
pattern: modifierByFileExtensionSchema,
2936
ignorePackages: {
30-
type: 'boolean' as const,
37+
type: 'boolean',
3138
},
3239
checkTypeImports: {
33-
type: 'boolean' as const,
40+
type: 'boolean',
41+
},
42+
pathGroupOverrides: {
43+
type: 'array',
44+
items: {
45+
type: 'object',
46+
properties: {
47+
pattern: { type: 'string' },
48+
patternOptions: { type: 'object' },
49+
action: {
50+
type: 'string',
51+
enum: ['enforce', 'ignore'],
52+
},
53+
},
54+
additionalProperties: false,
55+
required: ['pattern', 'action'],
56+
},
57+
},
58+
fix: {
59+
type: 'boolean',
3460
},
3561
},
36-
}
62+
} satisfies JSONSchema4
3763

3864
export type Modifier = (typeof modifierValues)[number]
3965

@@ -43,15 +69,27 @@ export interface OptionsItemWithPatternProperty {
4369
ignorePackages?: boolean
4470
checkTypeImports?: boolean
4571
pattern: ModifierByFileExtension
72+
pathGroupOverrides?: PathGroupOverride[]
73+
fix?: boolean
74+
}
75+
76+
export interface PathGroupOverride {
77+
pattern: string
78+
patternOptions?: Record<string, MinimatchOptions>
79+
action: 'enforce' | 'ignore'
4680
}
4781

4882
export interface OptionsItemWithoutPatternProperty {
4983
ignorePackages?: boolean
5084
checkTypeImports?: boolean
85+
pathGroupOverrides?: PathGroupOverride[]
86+
fix?: boolean
5187
}
5288

5389
export type Options =
5490
| []
91+
| [OptionsItemWithoutPatternProperty]
92+
| [OptionsItemWithPatternProperty]
5593
| [Modifier]
5694
| [Modifier, OptionsItemWithoutPatternProperty]
5795
| [Modifier, OptionsItemWithPatternProperty]
@@ -63,16 +101,20 @@ export interface NormalizedOptions {
63101
pattern?: Record<string, Modifier>
64102
ignorePackages?: boolean
65103
checkTypeImports?: boolean
104+
pathGroupOverrides?: PathGroupOverride[]
105+
fix?: boolean
66106
}
67107

68-
export type MessageId = 'missing' | 'missingKnown' | 'unexpected'
108+
export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing'
69109

70110
function buildProperties(context: RuleContext<MessageId, Options>) {
71111
const result: Required<NormalizedOptions> = {
72112
defaultConfig: 'never',
73113
pattern: {},
74114
ignorePackages: false,
75115
checkTypeImports: false,
116+
pathGroupOverrides: [],
117+
fix: false,
76118
}
77119

78120
for (const obj of context.options) {
@@ -88,16 +130,16 @@ function buildProperties(context: RuleContext<MessageId, Options>) {
88130

89131
// If this is not the new structure, transfer all props to result.pattern
90132
if (
91-
(!('pattern' in obj) || obj.pattern === undefined) &&
92-
obj.ignorePackages === undefined &&
93-
obj.checkTypeImports === undefined
133+
(!('pattern' in obj) || obj.pattern == null) &&
134+
obj.ignorePackages == null &&
135+
obj.checkTypeImports == null
94136
) {
95137
Object.assign(result.pattern, obj)
96138
continue
97139
}
98140

99141
// If pattern is provided, transfer all props
100-
if ('pattern' in obj && obj.pattern !== undefined) {
142+
if ('pattern' in obj && obj.pattern != null) {
101143
Object.assign(result.pattern, obj.pattern)
102144
}
103145

@@ -109,6 +151,14 @@ function buildProperties(context: RuleContext<MessageId, Options>) {
109151
if (typeof obj.checkTypeImports === 'boolean') {
110152
result.checkTypeImports = obj.checkTypeImports
111153
}
154+
155+
if (obj.fix != null) {
156+
result.fix = Boolean(obj.fix)
157+
}
158+
159+
if (Array.isArray(obj.pathGroupOverrides)) {
160+
result.pathGroupOverrides = obj.pathGroupOverrides
161+
}
112162
}
113163

114164
if (result.defaultConfig === 'ignorePackages') {
@@ -124,14 +174,18 @@ function isExternalRootModule(file: string) {
124174
return false
125175
}
126176
const slashCount = file.split('/').length - 1
177+
return slashCount === 0 || (isScoped(file) && slashCount <= 1)
178+
}
127179

128-
if (slashCount === 0) {
129-
return true
130-
}
131-
if (isScoped(file) && slashCount <= 1) {
132-
return true
180+
function computeOverrideAction(
181+
pathGroupOverrides: PathGroupOverride[],
182+
path: string,
183+
) {
184+
for (const { pattern, patternOptions, action } of pathGroupOverrides) {
185+
if (minimatch(path, pattern, patternOptions || { nocomment: true })) {
186+
return action
187+
}
133188
}
134-
return false
135189
}
136190

137191
export default createRule<Options, MessageId>({
@@ -143,6 +197,8 @@ export default createRule<Options, MessageId>({
143197
description:
144198
'Ensure consistent use of file extension within the import path.',
145199
},
200+
fixable: 'code',
201+
hasSuggestions: true,
146202
schema: {
147203
anyOf: [
148204
{
@@ -178,6 +234,8 @@ export default createRule<Options, MessageId>({
178234
'Missing file extension "{{extension}}" for "{{importPath}}"',
179235
unexpected:
180236
'Unexpected use of file extension "{{extension}}" for "{{importPath}}"',
237+
addMissing:
238+
'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"',
181239
},
182240
},
183241
defaultOptions: [],
@@ -221,16 +279,29 @@ export default createRule<Options, MessageId>({
221279

222280
const importPathWithQueryString = source.value
223281

282+
// If not undefined, the user decided if rules are enforced on this import
283+
const overrideAction = computeOverrideAction(
284+
props.pathGroupOverrides || [],
285+
importPathWithQueryString,
286+
)
287+
288+
if (overrideAction === 'ignore') {
289+
return
290+
}
291+
224292
// don't enforce anything on builtins
225-
if (isBuiltIn(importPathWithQueryString, context.settings)) {
293+
if (
294+
!overrideAction &&
295+
isBuiltIn(importPathWithQueryString, context.settings)
296+
) {
226297
return
227298
}
228299

229300
const importPath = importPathWithQueryString.replace(/\?(.*)$/, '')
230301

231302
// don't enforce in root external packages as they may have names with `.js`.
232303
// Like `import Decimal from decimal.js`)
233-
if (isExternalRootModule(importPath)) {
304+
if (!overrideAction && isExternalRootModule(importPath)) {
234305
return
235306
}
236307

@@ -261,17 +332,55 @@ export default createRule<Options, MessageId>({
261332
}
262333
const extensionRequired = isUseOfExtensionRequired(
263334
extension,
264-
isPackage,
335+
!overrideAction && isPackage,
265336
)
266337
const extensionForbidden = isUseOfExtensionForbidden(extension)
267338
if (extensionRequired && !extensionForbidden) {
339+
const { pathname, query, hash } = parsePath(
340+
importPathWithQueryString,
341+
)
342+
const fixedImportPath = stringifyPath({
343+
pathname: `${
344+
/([\\/]|[\\/]?\.?\.)$/.test(pathname)
345+
? `${
346+
pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
347+
}/index.${extension}`
348+
: `${pathname}.${extension}`
349+
}`,
350+
query,
351+
hash,
352+
})
353+
const fixOrSuggest = {
354+
fix(fixer: RuleFixer) {
355+
return fixer.replaceText(
356+
source,
357+
JSON.stringify(fixedImportPath),
358+
)
359+
},
360+
}
268361
context.report({
269362
node: source,
270363
messageId: extension ? 'missingKnown' : 'missing',
271364
data: {
272365
extension,
273366
importPath: importPathWithQueryString,
274367
},
368+
...(extension &&
369+
(props.fix
370+
? fixOrSuggest
371+
: {
372+
suggest: [
373+
{
374+
...fixOrSuggest,
375+
messageId: 'addMissing',
376+
data: {
377+
extension,
378+
importPath: importPathWithQueryString,
379+
fixedImportPath: fixedImportPath,
380+
},
381+
},
382+
],
383+
})),
275384
})
276385
}
277386
} else if (
@@ -286,6 +395,14 @@ export default createRule<Options, MessageId>({
286395
extension,
287396
importPath: importPathWithQueryString,
288397
},
398+
...(props.fix && {
399+
fix(fixer) {
400+
return fixer.replaceText(
401+
source,
402+
JSON.stringify(importPath.slice(0, -(extension.length + 1))),
403+
)
404+
},
405+
}),
289406
})
290407
}
291408
},

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './lazy-value.js'
1515
export * from './legacy-resolver-settings.js'
1616
export * from './package-path.js'
1717
export * from './parse.js'
18+
export * from './parse-path.js'
1819
export * from './pkg-dir.js'
1920
export * from './pkg-up.js'
2021
export * from './read-pkg-up.js'

src/utils/parse-path.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface ParsedPath {
2+
pathname: string
3+
query: string
4+
hash: string
5+
}
6+
7+
export const parsePath = (path: string): ParsedPath => {
8+
const hashIndex = path.indexOf('#')
9+
const queryIndex = path.indexOf('?')
10+
const hasHash = hashIndex !== -1
11+
const hash = hasHash ? path.slice(hashIndex) : ''
12+
const hasQuery = queryIndex !== -1 && (!hasHash || queryIndex < hashIndex)
13+
const query = hasQuery
14+
? path.slice(queryIndex, hasHash ? hashIndex : undefined)
15+
: ''
16+
const pathname = hasQuery
17+
? path.slice(0, queryIndex)
18+
: hasHash
19+
? path.slice(0, hashIndex)
20+
: path
21+
return { pathname, query, hash }
22+
}
23+
24+
export const stringifyPath = ({ pathname, query, hash }: ParsedPath) =>
25+
pathname + query + hash

0 commit comments

Comments
 (0)