Skip to content

Commit ffe866c

Browse files
JounQinXunnamius
andcommitted
refactor: add suggestions support, add test cases
Co-authored-by: "Xunnamius (Romulus)" <[email protected]>
1 parent 8cd5a89 commit ffe866c

File tree

9 files changed

+380
-55
lines changed

9 files changed

+380
-55
lines changed

.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: 95 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
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'
35
import { minimatch } from 'minimatch'
6+
import type { MinimatchOptions } from 'minimatch'
47

58
import type { FileExtension, RuleContext } from '../types.js'
69
import {
@@ -10,48 +13,53 @@ import {
1013
createRule,
1114
moduleVisitor,
1215
resolve,
16+
parsePath,
17+
stringifyPath,
1318
} from '../utils/index.js'
1419

1520
const modifierValues = ['always', 'ignorePackages', 'never'] as const
1621

1722
const modifierSchema = {
18-
type: 'string' as const,
23+
type: 'string',
1924
enum: [...modifierValues],
20-
}
25+
} satisfies JSONSchema4
2126

2227
const modifierByFileExtensionSchema = {
23-
type: 'object' as const,
28+
type: 'object',
2429
patternProperties: { '.*': modifierSchema },
25-
}
30+
} satisfies JSONSchema4
2631

2732
const properties = {
28-
type: 'object' as const,
33+
type: 'object',
2934
properties: {
3035
pattern: modifierByFileExtensionSchema,
3136
ignorePackages: {
32-
type: 'boolean' as const,
37+
type: 'boolean',
3338
},
3439
checkTypeImports: {
35-
type: 'boolean' as const,
40+
type: 'boolean',
3641
},
3742
pathGroupOverrides: {
38-
type: 'array' as const,
43+
type: 'array',
3944
items: {
40-
type: 'object' as const,
45+
type: 'object',
4146
properties: {
42-
pattern: { type: 'string' as const },
43-
patternOptions: { type: 'object' as const },
47+
pattern: { type: 'string' },
48+
patternOptions: { type: 'object' },
4449
action: {
45-
type: 'string' as const,
50+
type: 'string',
4651
enum: ['enforce', 'ignore'],
4752
},
4853
},
4954
additionalProperties: false,
5055
required: ['pattern', 'action'],
5156
},
5257
},
58+
fix: {
59+
type: 'boolean',
60+
},
5361
},
54-
}
62+
} satisfies JSONSchema4
5563

5664
export type Modifier = (typeof modifierValues)[number]
5765

@@ -61,25 +69,27 @@ export interface OptionsItemWithPatternProperty {
6169
ignorePackages?: boolean
6270
checkTypeImports?: boolean
6371
pattern: ModifierByFileExtension
64-
fix?: boolean
6572
pathGroupOverrides?: PathGroupOverride[]
73+
fix?: boolean
6674
}
6775

6876
export interface PathGroupOverride {
6977
pattern: string
70-
patternOptions?: Record<string, any>
78+
patternOptions?: Record<string, MinimatchOptions>
7179
action: 'enforce' | 'ignore'
7280
}
7381

7482
export interface OptionsItemWithoutPatternProperty {
7583
ignorePackages?: boolean
7684
checkTypeImports?: boolean
77-
fix?: boolean
7885
pathGroupOverrides?: PathGroupOverride[]
86+
fix?: boolean
7987
}
8088

8189
export type Options =
8290
| []
91+
| [OptionsItemWithoutPatternProperty]
92+
| [OptionsItemWithPatternProperty]
8393
| [Modifier]
8494
| [Modifier, OptionsItemWithoutPatternProperty]
8595
| [Modifier, OptionsItemWithPatternProperty]
@@ -91,20 +101,20 @@ export interface NormalizedOptions {
91101
pattern?: Record<string, Modifier>
92102
ignorePackages?: boolean
93103
checkTypeImports?: boolean
94-
fix?: boolean
95104
pathGroupOverrides?: PathGroupOverride[]
105+
fix?: boolean
96106
}
97107

98-
export type MessageId = 'missing' | 'missingKnown' | 'unexpected'
108+
export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing'
99109

100110
function buildProperties(context: RuleContext<MessageId, Options>) {
101111
const result: Required<NormalizedOptions> = {
102112
defaultConfig: 'never',
103113
pattern: {},
104114
ignorePackages: false,
105115
checkTypeImports: false,
106-
fix: false,
107116
pathGroupOverrides: [],
117+
fix: false,
108118
}
109119

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

121131
// If this is not the new structure, transfer all props to result.pattern
122132
if (
123-
(!('pattern' in obj) || obj.pattern === undefined) &&
124-
obj.ignorePackages === undefined &&
125-
obj.checkTypeImports === undefined
133+
(!('pattern' in obj) || obj.pattern == null) &&
134+
obj.ignorePackages == null &&
135+
obj.checkTypeImports == null
126136
) {
127137
Object.assign(result.pattern, obj)
128138
continue
129139
}
130140

131141
// If pattern is provided, transfer all props
132-
if ('pattern' in obj && obj.pattern !== undefined) {
142+
if ('pattern' in obj && obj.pattern != null) {
133143
Object.assign(result.pattern, obj.pattern)
134144
}
135145

@@ -142,11 +152,11 @@ function buildProperties(context: RuleContext<MessageId, Options>) {
142152
result.checkTypeImports = obj.checkTypeImports
143153
}
144154

145-
if ('fix' in obj) {
155+
if (obj.fix != null) {
146156
result.fix = Boolean(obj.fix)
147157
}
148158

149-
if ('pathGroupOverrides' in obj && Array.isArray(obj.pathGroupOverrides)) {
159+
if (Array.isArray(obj.pathGroupOverrides)) {
150160
result.pathGroupOverrides = obj.pathGroupOverrides
151161
}
152162
}
@@ -167,8 +177,11 @@ function isExternalRootModule(file: string) {
167177
return slashCount === 0 || (isScoped(file) && slashCount <= 1)
168178
}
169179

170-
function computeOverrideAction(overrides: PathGroupOverride[], path: string) {
171-
for (const { pattern, patternOptions, action } of overrides) {
180+
function computeOverrideAction(
181+
pathGroupOverrides: PathGroupOverride[],
182+
path: string,
183+
) {
184+
for (const { pattern, patternOptions, action } of pathGroupOverrides) {
172185
if (minimatch(path, pattern, patternOptions || { nocomment: true })) {
173186
return action
174187
}
@@ -185,6 +198,7 @@ export default createRule<Options, MessageId>({
185198
'Ensure consistent use of file extension within the import path.',
186199
},
187200
fixable: 'code',
201+
hasSuggestions: true,
188202
schema: {
189203
anyOf: [
190204
{
@@ -220,6 +234,8 @@ export default createRule<Options, MessageId>({
220234
'Missing file extension "{{extension}}" for "{{importPath}}"',
221235
unexpected:
222236
'Unexpected use of file extension "{{extension}}" for "{{importPath}}"',
237+
addMissing:
238+
'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"',
223239
},
224240
},
225241
defaultOptions: [],
@@ -262,11 +278,16 @@ export default createRule<Options, MessageId>({
262278
}
263279

264280
const importPathWithQueryString = source.value
281+
282+
// If not undefined, the user decided if rules are enforced on this import
265283
const overrideAction = computeOverrideAction(
266284
props.pathGroupOverrides || [],
267285
importPathWithQueryString,
268286
)
269-
if (overrideAction === 'ignore') return
287+
288+
if (overrideAction === 'ignore') {
289+
return
290+
}
270291

271292
// don't enforce anything on builtins
272293
if (
@@ -315,25 +336,51 @@ export default createRule<Options, MessageId>({
315336
)
316337
const extensionForbidden = isUseOfExtensionForbidden(extension)
317338
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+
}
318361
context.report({
319362
node: source,
320363
messageId: extension ? 'missingKnown' : 'missing',
321364
data: {
322365
extension,
323366
importPath: importPathWithQueryString,
324367
},
325-
...(props.fix && extension
326-
? {
327-
fix(fixer) {
328-
return fixer.replaceText(
329-
source,
330-
JSON.stringify(
331-
`${importPathWithQueryString}.${extension}`,
332-
),
333-
)
334-
},
335-
}
336-
: {}),
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+
})),
337384
})
338385
}
339386
} else if (
@@ -348,18 +395,14 @@ export default createRule<Options, MessageId>({
348395
extension,
349396
importPath: importPathWithQueryString,
350397
},
351-
...(props.fix
352-
? {
353-
fix(fixer) {
354-
return fixer.replaceText(
355-
source,
356-
JSON.stringify(
357-
importPath.slice(0, -(extension.length + 1)),
358-
),
359-
)
360-
},
361-
}
362-
: {}),
398+
...(props.fix && {
399+
fix(fixer) {
400+
return fixer.replaceText(
401+
source,
402+
JSON.stringify(importPath.slice(0, -(extension.length + 1))),
403+
)
404+
},
405+
}),
363406
})
364407
}
365408
},

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) => {
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)