@@ -5,7 +5,7 @@ import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint'
5
5
import { minimatch } from 'minimatch'
6
6
import type { MinimatchOptions } from 'minimatch'
7
7
8
- import type { FileExtension , RuleContext } from '../types.js'
8
+ import type { RuleContext } from '../types.js'
9
9
import {
10
10
isBuiltIn ,
11
11
isExternalModule ,
@@ -105,7 +105,12 @@ export interface NormalizedOptions {
105
105
fix ?: boolean
106
106
}
107
107
108
- export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing'
108
+ export type MessageId =
109
+ | 'missing'
110
+ | 'missingKnown'
111
+ | 'unexpected'
112
+ | 'addMissing'
113
+ | 'removeUnexpected'
109
114
110
115
function buildProperties ( context : RuleContext < MessageId , Options > ) {
111
116
const result : Required < NormalizedOptions > = {
@@ -188,6 +193,20 @@ function computeOverrideAction(
188
193
}
189
194
}
190
195
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
+
191
210
export default createRule < Options , MessageId > ( {
192
211
name : 'extensions' ,
193
212
meta : {
@@ -236,27 +255,26 @@ export default createRule<Options, MessageId>({
236
255
'Unexpected use of file extension "{{extension}}" for "{{importPath}}"' ,
237
256
addMissing :
238
257
'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"' ,
258
+ removeUnexpected :
259
+ 'Remove unexpected "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"' ,
239
260
} ,
240
261
} ,
241
262
defaultOptions : [ ] ,
242
263
create ( context ) {
243
264
const props = buildProperties ( context )
244
265
245
- function getModifier ( extension : FileExtension ) {
266
+ function getModifier ( extension : string ) {
246
267
return props . pattern [ extension ] || props . defaultConfig
247
268
}
248
269
249
- function isUseOfExtensionRequired (
250
- extension : FileExtension ,
251
- isPackage : boolean ,
252
- ) {
270
+ function isUseOfExtensionRequired ( extension : string , isPackage : boolean ) {
253
271
return (
254
272
getModifier ( extension ) === 'always' &&
255
273
( ! props . ignorePackages || ! isPackage )
256
274
)
257
275
}
258
276
259
- function isUseOfExtensionForbidden ( extension : FileExtension ) {
277
+ function isUseOfExtensionForbidden ( extension : string ) {
260
278
return getModifier ( extension ) === 'never'
261
279
}
262
280
@@ -297,7 +315,11 @@ export default createRule<Options, MessageId>({
297
315
return
298
316
}
299
317
300
- const importPath = importPathWithQueryString . replace ( / \? ( .* ) $ / , '' )
318
+ const {
319
+ pathname : importPath ,
320
+ query,
321
+ hash,
322
+ } = parsePath ( importPathWithQueryString )
301
323
302
324
// don't enforce in root external packages as they may have names with `.js`.
303
325
// Like `import Decimal from decimal.js`)
@@ -309,9 +331,7 @@ export default createRule<Options, MessageId>({
309
331
310
332
// get extension from resolved path, if possible.
311
333
// 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 )
315
335
316
336
// determine if this is a module
317
337
const isPackage =
@@ -336,16 +356,15 @@ export default createRule<Options, MessageId>({
336
356
)
337
357
const extensionForbidden = isUseOfExtensionForbidden ( extension )
338
358
if ( extensionRequired && ! extensionForbidden ) {
339
- const { pathname, query, hash } = parsePath (
340
- importPathWithQueryString ,
341
- )
342
359
const fixedImportPath = stringifyPath ( {
343
360
pathname : `${
344
- / ( [ \\ / ] | [ \\ / ] ? \. ? \. ) $ / . test ( pathname )
361
+ / ( [ \\ / ] | [ \\ / ] ? \. ? \. ) $ / . test ( importPath )
345
362
? `${
346
- pathname . endsWith ( '/' ) ? pathname . slice ( 0 , - 1 ) : pathname
363
+ importPath . endsWith ( '/' )
364
+ ? importPath . slice ( 0 , - 1 )
365
+ : importPath
347
366
} /index.${ extension } `
348
- : `${ pathname } .${ extension } `
367
+ : `${ importPath } .${ extension } `
349
368
} `,
350
369
query,
351
370
hash,
@@ -354,7 +373,7 @@ export default createRule<Options, MessageId>({
354
373
fix ( fixer : RuleFixer ) {
355
374
return fixer . replaceText (
356
375
source ,
357
- JSON . stringify ( fixedImportPath ) ,
376
+ replaceImportPath ( source . raw , fixedImportPath ) ,
358
377
)
359
378
} ,
360
379
}
@@ -376,7 +395,7 @@ export default createRule<Options, MessageId>({
376
395
data : {
377
396
extension,
378
397
importPath : importPathWithQueryString ,
379
- fixedImportPath : fixedImportPath ,
398
+ fixedImportPath,
380
399
} ,
381
400
} ,
382
401
] ,
@@ -388,21 +407,68 @@ export default createRule<Options, MessageId>({
388
407
isUseOfExtensionForbidden ( extension ) &&
389
408
isResolvableWithoutExtension ( importPath )
390
409
) {
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
+ }
391
434
context . report ( {
392
435
node : source ,
393
436
messageId : 'unexpected' ,
394
437
data : {
395
438
extension,
396
439
importPath : importPathWithQueryString ,
397
440
} ,
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
+ } ) ,
406
472
} )
407
473
}
408
474
} ,
0 commit comments