@@ -5,7 +5,7 @@ import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint'
55import { minimatch } from 'minimatch'
66import type { MinimatchOptions } from 'minimatch'
77
8- import type { FileExtension , RuleContext } from '../types.js'
8+ import type { RuleContext } from '../types.js'
99import {
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
110115function 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+
191210export 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