Skip to content

Commit 813a4a0

Browse files
committed
feat: add suggestions support for extensions unexpected case
follow #327
1 parent b80490e commit 813a4a0

File tree

5 files changed

+490
-43
lines changed

5 files changed

+490
-43
lines changed

.changeset/weak-points-pull.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": patch
3+
---
4+
5+
feat: add suggestions support for `extensions` `unexpected` case

src/rules/extensions.ts

Lines changed: 94 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint'
55
import { minimatch } from 'minimatch'
66
import type { MinimatchOptions } from 'minimatch'
77

8-
import type { FileExtension, RuleContext } from '../types.js'
8+
import type { RuleContext } from '../types.js'
99
import {
1010
isBuiltIn,
1111
isExternalModule,
@@ -105,7 +105,12 @@ export interface NormalizedOptions {
105105
fix?: boolean
106106
}
107107

108-
export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing'
108+
export type MessageId =
109+
| 'missing'
110+
| 'missingKnown'
111+
| 'unexpected'
112+
| 'addMissing'
113+
| 'removeUnexpected'
109114

110115
function buildProperties(context: RuleContext<MessageId, Options>) {
111116
const result: Required<NormalizedOptions> = {
@@ -188,6 +193,20 @@ function computeOverrideAction(
188193
}
189194
}
190195

196+
/**
197+
* Replaces the import path in a source string with a new import path.
198+
*
199+
* @param source - The original source string containing the import statement.
200+
* @param importPath - The new import path to replace the existing one.
201+
* @returns The updated source string with the replaced import path.
202+
*/
203+
function replaceImportPath(source: string, importPath: string) {
204+
return source.replace(
205+
/^(['"])(.+)\1$/,
206+
(_, quote: string) => `${quote}${importPath}${quote}`,
207+
)
208+
}
209+
191210
export default createRule<Options, MessageId>({
192211
name: 'extensions',
193212
meta: {
@@ -236,27 +255,26 @@ export default createRule<Options, MessageId>({
236255
'Unexpected use of file extension "{{extension}}" for "{{importPath}}"',
237256
addMissing:
238257
'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"',
258+
removeUnexpected:
259+
'Remove unexpected "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"',
239260
},
240261
},
241262
defaultOptions: [],
242263
create(context) {
243264
const props = buildProperties(context)
244265

245-
function getModifier(extension: FileExtension) {
266+
function getModifier(extension: string) {
246267
return props.pattern[extension] || props.defaultConfig
247268
}
248269

249-
function isUseOfExtensionRequired(
250-
extension: FileExtension,
251-
isPackage: boolean,
252-
) {
270+
function isUseOfExtensionRequired(extension: string, isPackage: boolean) {
253271
return (
254272
getModifier(extension) === 'always' &&
255273
(!props.ignorePackages || !isPackage)
256274
)
257275
}
258276

259-
function isUseOfExtensionForbidden(extension: FileExtension) {
277+
function isUseOfExtensionForbidden(extension: string) {
260278
return getModifier(extension) === 'never'
261279
}
262280

@@ -297,7 +315,11 @@ export default createRule<Options, MessageId>({
297315
return
298316
}
299317

300-
const importPath = importPathWithQueryString.replace(/\?(.*)$/, '')
318+
const {
319+
pathname: importPath,
320+
query,
321+
hash,
322+
} = parsePath(importPathWithQueryString)
301323

302324
// don't enforce in root external packages as they may have names with `.js`.
303325
// Like `import Decimal from decimal.js`)
@@ -309,9 +331,7 @@ export default createRule<Options, MessageId>({
309331

310332
// get extension from resolved path, if possible.
311333
// for unresolved, use source value.
312-
const extension = path
313-
.extname(resolvedPath || importPath)
314-
.slice(1) as FileExtension
334+
const extension = path.extname(resolvedPath || importPath).slice(1)
315335

316336
// determine if this is a module
317337
const isPackage =
@@ -336,16 +356,15 @@ export default createRule<Options, MessageId>({
336356
)
337357
const extensionForbidden = isUseOfExtensionForbidden(extension)
338358
if (extensionRequired && !extensionForbidden) {
339-
const { pathname, query, hash } = parsePath(
340-
importPathWithQueryString,
341-
)
342359
const fixedImportPath = stringifyPath({
343360
pathname: `${
344-
/([\\/]|[\\/]?\.?\.)$/.test(pathname)
361+
/([\\/]|[\\/]?\.?\.)$/.test(importPath)
345362
? `${
346-
pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
363+
importPath.endsWith('/')
364+
? importPath.slice(0, -1)
365+
: importPath
347366
}/index.${extension}`
348-
: `${pathname}.${extension}`
367+
: `${importPath}.${extension}`
349368
}`,
350369
query,
351370
hash,
@@ -354,7 +373,7 @@ export default createRule<Options, MessageId>({
354373
fix(fixer: RuleFixer) {
355374
return fixer.replaceText(
356375
source,
357-
JSON.stringify(fixedImportPath),
376+
replaceImportPath(source.raw, fixedImportPath),
358377
)
359378
},
360379
}
@@ -376,7 +395,7 @@ export default createRule<Options, MessageId>({
376395
data: {
377396
extension,
378397
importPath: importPathWithQueryString,
379-
fixedImportPath: fixedImportPath,
398+
fixedImportPath,
380399
},
381400
},
382401
],
@@ -388,21 +407,68 @@ export default createRule<Options, MessageId>({
388407
isUseOfExtensionForbidden(extension) &&
389408
isResolvableWithoutExtension(importPath)
390409
) {
410+
const fixedPathname = importPath.slice(0, -(extension.length + 1))
411+
const isIndex = fixedPathname.endsWith('/index')
412+
const fixedImportPath = stringifyPath({
413+
pathname: isIndex ? fixedPathname.slice(0, -6) : fixedPathname,
414+
query,
415+
hash,
416+
})
417+
const fixOrSuggest = {
418+
fix(fixer: RuleFixer) {
419+
return fixer.replaceText(
420+
source,
421+
replaceImportPath(source.raw, fixedImportPath),
422+
)
423+
},
424+
}
425+
const commonSuggestion = {
426+
...fixOrSuggest,
427+
messageId: 'removeUnexpected' as const,
428+
data: {
429+
extension,
430+
importPath: importPathWithQueryString,
431+
fixedImportPath,
432+
},
433+
}
391434
context.report({
392435
node: source,
393436
messageId: 'unexpected',
394437
data: {
395438
extension,
396439
importPath: importPathWithQueryString,
397440
},
398-
...(props.fix && {
399-
fix(fixer) {
400-
return fixer.replaceText(
401-
source,
402-
JSON.stringify(importPath.slice(0, -(extension.length + 1))),
403-
)
404-
},
405-
}),
441+
...(props.fix
442+
? fixOrSuggest
443+
: {
444+
suggest: [
445+
commonSuggestion,
446+
isIndex && {
447+
...commonSuggestion,
448+
fix(fixer: RuleFixer) {
449+
return fixer.replaceText(
450+
source,
451+
replaceImportPath(
452+
source.raw,
453+
stringifyPath({
454+
pathname: fixedPathname,
455+
query,
456+
hash,
457+
}),
458+
),
459+
)
460+
},
461+
data: {
462+
...commonSuggestion.data,
463+
fixedImportPath: stringifyPath({
464+
pathname: fixedPathname,
465+
query,
466+
hash,
467+
}),
468+
},
469+
},
470+
].filter(Boolean),
471+
}),
406472
})
407473
}
408474
},

0 commit comments

Comments
 (0)