Skip to content

Commit 90c1cd0

Browse files
JounQinXunnamiusstephenjason89autofix-ci[bot]
authored
feat(extensions): support pathGroupOverrides and fix options (#327)
Co-authored-by: "Xunnamius (Romulus)" <Xunnamius@users.noreply.github.com> Co-authored-by: Stephen Jason Wang <stephenjasonwang@gmail.com> 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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy