diff --git a/.changeset/weak-points-pull.md b/.changeset/weak-points-pull.md new file mode 100644 index 00000000..6fb086b2 --- /dev/null +++ b/.changeset/weak-points-pull.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-import-x": patch +--- + +feat: add suggestions support for `extensions` `unexpected` case diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts index 27b3dbef..29d177e2 100644 --- a/src/rules/extensions.ts +++ b/src/rules/extensions.ts @@ -5,7 +5,7 @@ import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint' import { minimatch } from 'minimatch' import type { MinimatchOptions } from 'minimatch' -import type { FileExtension, RuleContext } from '../types.js' +import type { RuleContext } from '../types.js' import { isBuiltIn, isExternalModule, @@ -105,7 +105,12 @@ export interface NormalizedOptions { fix?: boolean } -export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing' +export type MessageId = + | 'missing' + | 'missingKnown' + | 'unexpected' + | 'addMissing' + | 'removeUnexpected' function buildProperties(context: RuleContext) { const result: Required = { @@ -188,6 +193,20 @@ function computeOverrideAction( } } +/** + * Replaces the import path in a source string with a new import path. + * + * @param source - The original source string containing the import statement. + * @param importPath - The new import path to replace the existing one. + * @returns The updated source string with the replaced import path. + */ +function replaceImportPath(source: string, importPath: string) { + return source.replace( + /^(['"])(.+)\1$/, + (_, quote: string) => `${quote}${importPath}${quote}`, + ) +} + export default createRule({ name: 'extensions', meta: { @@ -236,27 +255,26 @@ export default createRule({ 'Unexpected use of file extension "{{extension}}" for "{{importPath}}"', addMissing: 'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"', + removeUnexpected: + 'Remove unexpected "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"', }, }, defaultOptions: [], create(context) { const props = buildProperties(context) - function getModifier(extension: FileExtension) { + function getModifier(extension: string) { return props.pattern[extension] || props.defaultConfig } - function isUseOfExtensionRequired( - extension: FileExtension, - isPackage: boolean, - ) { + function isUseOfExtensionRequired(extension: string, isPackage: boolean) { return ( getModifier(extension) === 'always' && (!props.ignorePackages || !isPackage) ) } - function isUseOfExtensionForbidden(extension: FileExtension) { + function isUseOfExtensionForbidden(extension: string) { return getModifier(extension) === 'never' } @@ -297,7 +315,11 @@ export default createRule({ return } - const importPath = importPathWithQueryString.replace(/\?(.*)$/, '') + const { + pathname: importPath, + query, + hash, + } = parsePath(importPathWithQueryString) // don't enforce in root external packages as they may have names with `.js`. // Like `import Decimal from decimal.js`) @@ -309,9 +331,7 @@ export default createRule({ // get extension from resolved path, if possible. // for unresolved, use source value. - const extension = path - .extname(resolvedPath || importPath) - .slice(1) as FileExtension + const extension = path.extname(resolvedPath || importPath).slice(1) // determine if this is a module const isPackage = @@ -336,16 +356,15 @@ export default createRule({ ) const extensionForbidden = isUseOfExtensionForbidden(extension) if (extensionRequired && !extensionForbidden) { - const { pathname, query, hash } = parsePath( - importPathWithQueryString, - ) const fixedImportPath = stringifyPath({ pathname: `${ - /([\\/]|[\\/]?\.?\.)$/.test(pathname) + /([\\/]|[\\/]?\.?\.)$/.test(importPath) ? `${ - pathname.endsWith('/') ? pathname.slice(0, -1) : pathname + importPath.endsWith('/') + ? importPath.slice(0, -1) + : importPath }/index.${extension}` - : `${pathname}.${extension}` + : `${importPath}.${extension}` }`, query, hash, @@ -354,7 +373,7 @@ export default createRule({ fix(fixer: RuleFixer) { return fixer.replaceText( source, - JSON.stringify(fixedImportPath), + replaceImportPath(source.raw, fixedImportPath), ) }, } @@ -376,7 +395,7 @@ export default createRule({ data: { extension, importPath: importPathWithQueryString, - fixedImportPath: fixedImportPath, + fixedImportPath, }, }, ], @@ -388,6 +407,30 @@ export default createRule({ isUseOfExtensionForbidden(extension) && isResolvableWithoutExtension(importPath) ) { + const fixedPathname = importPath.slice(0, -(extension.length + 1)) + const isIndex = fixedPathname.endsWith('/index') + const fixedImportPath = stringifyPath({ + pathname: isIndex ? fixedPathname.slice(0, -6) : fixedPathname, + query, + hash, + }) + const fixOrSuggest = { + fix(fixer: RuleFixer) { + return fixer.replaceText( + source, + replaceImportPath(source.raw, fixedImportPath), + ) + }, + } + const commonSuggestion = { + ...fixOrSuggest, + messageId: 'removeUnexpected' as const, + data: { + extension, + importPath: importPathWithQueryString, + fixedImportPath, + }, + } context.report({ node: source, messageId: 'unexpected', @@ -395,14 +438,37 @@ export default createRule({ extension, importPath: importPathWithQueryString, }, - ...(props.fix && { - fix(fixer) { - return fixer.replaceText( - source, - JSON.stringify(importPath.slice(0, -(extension.length + 1))), - ) - }, - }), + ...(props.fix + ? fixOrSuggest + : { + suggest: [ + commonSuggestion, + isIndex && { + ...commonSuggestion, + fix(fixer: RuleFixer) { + return fixer.replaceText( + source, + replaceImportPath( + source.raw, + stringifyPath({ + pathname: fixedPathname, + query, + hash, + }), + ), + ) + }, + data: { + ...commonSuggestion.data, + fixedImportPath: stringifyPath({ + pathname: fixedPathname, + query, + hash, + }), + }, + }, + ].filter(Boolean), + }), }) } }, diff --git a/test/rules/extensions.spec.ts b/test/rules/extensions.spec.ts index 989801f4..01b97b3e 100644 --- a/test/rules/extensions.spec.ts +++ b/test/rules/extensions.spec.ts @@ -181,6 +181,26 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: 'a/index.js' }, line: 1, column: 15, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: 'a/index.js', + fixedImportPath: 'a', + }, + output: 'import a from "a"', + }, + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: 'a/index.js', + fixedImportPath: 'a/index', + }, + output: 'import a from "a/index"', + }, + ], }, ], }), @@ -220,6 +240,32 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: 'a/index.js' }, line: 1, column: 15, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: 'a/index.js', + fixedImportPath: 'a', + }, + output: [ + 'import a from "a"', + 'import packageConfig from "./package"', + ].join('\n'), + }, + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: 'a/index.js', + fixedImportPath: 'a/index', + }, + output: [ + 'import a from "a/index"', + 'import packageConfig from "./package"', + ].join('\n'), + }, + ], }, { messageId: 'missingKnown', @@ -259,6 +305,21 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './bar.js' }, line: 1, column: 17, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './bar.js', + fixedImportPath: './bar', + }, + output: [ + 'import lib from "./bar"', + 'import component from "./bar.jsx"', + 'import data from "./bar.json"', + ].join('\n'), + }, + ], }, ], }), @@ -278,6 +339,21 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './bar.js' }, line: 1, column: 17, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './bar.js', + fixedImportPath: './bar', + }, + output: [ + 'import lib from "./bar"', + 'import component from "./bar.jsx"', + 'import data from "./bar.json"', + ].join('\n'), + }, + ], }, ], }), @@ -297,6 +373,20 @@ ruleTester.run('extensions', rule, { data: { extension: 'jsx', importPath: './bar.jsx' }, line: 1, column: 23, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'jsx', + importPath: './bar.jsx', + fixedImportPath: './bar', + }, + output: [ + 'import component from "./bar"', + 'import data from "./bar.json"', + ].join('\n'), + }, + ], }, ], }), @@ -308,6 +398,17 @@ ruleTester.run('extensions', rule, { data: { extension: 'coffee', importPath: './bar.coffee' }, line: 1, column: 8, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'coffee', + importPath: './bar.coffee', + fixedImportPath: './bar', + }, + output: 'import "./bar"', + }, + ], }, ], options: ['never', { js: 'always', jsx: 'always' }], @@ -330,6 +431,21 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './bar.js' }, line: 1, column: 19, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './bar.js', + fixedImportPath: './bar', + }, + output: [ + 'import barjs from "./bar"', + 'import barjson from "./bar.json"', + 'import barnone from "./bar"', + ].join('\n'), + }, + ], }, ], }), @@ -397,6 +513,21 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './bar.js' }, line: 1, column: 19, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './bar.js', + fixedImportPath: './bar', + }, + output: [ + 'import barjs from "./bar"', + 'import barjson from "./bar.json"', + 'import barnone from "./bar"', + ].join('\n'), + }, + ], }, ], }), @@ -411,6 +542,17 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './fake-file.js' }, line: 1, column: 19, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './fake-file.js', + fixedImportPath: './fake-file', + }, + output: 'import thing from "./fake-file"', + }, + ], }, ], }), @@ -449,6 +591,17 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: '@name/pkg/test.js' }, line: 1, column: 19, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: '@name/pkg/test.js', + fixedImportPath: '@name/pkg/test', + }, + output: 'import thing from "@name/pkg/test"', + }, + ], }, ], }), @@ -518,15 +671,46 @@ ruleTester.run('extensions', rule, { { messageId: 'unexpected', data: { extension: 'js', importPath: './foo.js' }, - line: 2, column: 25, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './foo.js', + fixedImportPath: './foo', + }, + output: ` + import foo from './foo' + import bar from './bar.json' + import Component from './Component.jsx' + import express from 'express' + `, + }, + ], }, { messageId: 'unexpected', data: { extension: 'jsx', importPath: './Component.jsx' }, line: 4, column: 31, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'jsx', + importPath: './Component.jsx', + fixedImportPath: './Component', + }, + output: ` + import foo from './foo.js' + import bar from './bar.json' + import Component from './Component' + import express from 'express' + `, + }, + ], }, ], options: ['never', { ignorePackages: true }], @@ -544,6 +728,21 @@ ruleTester.run('extensions', rule, { data: { extension: 'jsx', importPath: './Component.jsx' }, line: 4, column: 31, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'jsx', + importPath: './Component.jsx', + fixedImportPath: './Component', + }, + output: ` + import foo from './foo.js' + import bar from './bar.json' + import Component from './Component' + `, + }, + ], }, ], options: ['always', { pattern: { jsx: 'never' } }], @@ -575,6 +774,20 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './foo.js' }, line: 1, column: 21, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './foo.js', + fixedImportPath: './foo', + }, + output: [ + 'export { foo } from "./foo"', + 'let bar; export { bar }', + ].join('\n'), + }, + ], }, ], }), @@ -589,6 +802,17 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './foo.js?a=True' }, line: 1, column: 27, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './foo.js?a=True', + fixedImportPath: './foo?a=True', + }, + output: 'import withExtension from "./foo?a=True"', + }, + ], }, ], }), @@ -629,6 +853,20 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './foo.js' }, line: 1, column: 25, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './foo.js', + fixedImportPath: './foo', + }, + output: [ + 'const { foo } = require("./foo")', + 'export { foo }', + ].join('\n'), + }, + ], }, ], }), @@ -674,6 +912,17 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './foo.js' }, line: 1, column: 21, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './foo.js', + fixedImportPath: './foo', + }, + output: 'export { foo } from "./foo"', + }, + ], }, ], }), @@ -698,9 +947,19 @@ ruleTester.run('extensions', rule, { { messageId: 'unexpected', data: { extension: 'js', importPath: './foo.js' }, - line: 1, column: 15, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './foo.js', + fixedImportPath: './foo', + }, + output: 'export * from "./foo"', + }, + ], }, ], }), @@ -711,8 +970,18 @@ ruleTester.run('extensions', rule, { { messageId: 'unexpected', data: { extension: 'js', importPath: '@/ImNotAScopedModule.js' }, - line: 1, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: '@/ImNotAScopedModule.js', + fixedImportPath: '@/ImNotAScopedModule', + }, + output: 'import foo from "@/ImNotAScopedModule"', + }, + ], }, ], }), @@ -739,6 +1008,36 @@ ruleTester.run('extensions', rule, { importPath: '@test-scope/some-module/index.js', }, line: 3, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: '@test-scope/some-module/index.js', + fixedImportPath: '@test-scope/some-module', + }, + output: ` + import _ from 'lodash'; + import m from '@test-scope/some-module'; + + import bar from './bar'; + `, + }, + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: '@test-scope/some-module/index.js', + fixedImportPath: '@test-scope/some-module/index', + }, + output: ` + import _ from 'lodash'; + import m from '@test-scope/some-module/index'; + + import bar from './bar'; + `, + }, + ], }, ], }), @@ -1024,6 +1323,83 @@ describe('TypeScript', () => { ], output: 'import foo from "./foo";', }), + + tInvalid({ + code: 'import foo from "./index.js?query#hash";', + options: ['always', { pattern: { js: 'never' } }], + errors: [ + { + messageId: 'unexpected', + data: { extension: 'js', importPath: './index.js?query#hash' }, + suggestions: [ + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './index.js?query#hash', + fixedImportPath: '.?query#hash', + }, + output: 'import foo from ".?query#hash";', + }, + { + messageId: 'removeUnexpected', + data: { + extension: 'js', + importPath: './index.js?query#hash', + fixedImportPath: './index?query#hash', + }, + output: 'import foo from "./index?query#hash";', + }, + ], + }, + ], + }), + + tInvalid({ + code: 'import foo from "./index.js?query#hash";', + options: ['always', { pattern: { js: 'never' }, fix: true }], + errors: [ + { + messageId: 'unexpected', + data: { extension: 'js', importPath: './index.js?query#hash' }, + }, + ], + output: 'import foo from ".?query#hash";', + }), + + tInvalid({ + code: 'import foo from "./?query#hash";', + options: ['always', { pattern: { js: 'always' } }], + errors: [ + { + messageId: 'missingKnown', + data: { extension: 'js', importPath: './?query#hash' }, + suggestions: [ + { + messageId: 'addMissing', + data: { + extension: 'js', + importPath: './?query#hash', + fixedImportPath: './index.js?query#hash', + }, + output: 'import foo from "./index.js?query#hash";', + }, + ], + }, + ], + }), + + tInvalid({ + code: 'import foo from "./?query#hash";', + options: ['always', { pattern: { js: 'always' }, fix: true }], + errors: [ + { + messageId: 'missingKnown', + data: { extension: 'js', importPath: './?query#hash' }, + }, + ], + output: 'import foo from "./index.js?query#hash";', + }), ], }) }) diff --git a/test/utils/__snapshots__/parse-path.spec.ts.snap b/test/utils/__snapshots__/parse-path.spec.ts.snap index 2d55a2b1..acdb3070 100644 --- a/test/utils/__snapshots__/parse-path.spec.ts.snap +++ b/test/utils/__snapshots__/parse-path.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`parse-path should parse and stringify path expectedly 1`] = ` +exports[`parse-path should parse and stringify path expectedly: foo 1`] = ` { "hash": "", "pathname": "foo", @@ -8,34 +8,34 @@ exports[`parse-path should parse and stringify path expectedly 1`] = ` } `; -exports[`parse-path should parse and stringify path expectedly 2`] = ` +exports[`parse-path should parse and stringify path expectedly: foo#hash 1`] = ` { - "hash": "", + "hash": "#hash", "pathname": "foo", - "query": "?query", + "query": "", } `; -exports[`parse-path should parse and stringify path expectedly 3`] = ` +exports[`parse-path should parse and stringify path expectedly: foo#hash?query 1`] = ` { - "hash": "#hash", + "hash": "#hash?query", "pathname": "foo", "query": "", } `; -exports[`parse-path should parse and stringify path expectedly 4`] = ` +exports[`parse-path should parse and stringify path expectedly: foo?query 1`] = ` { - "hash": "#hash", + "hash": "", "pathname": "foo", "query": "?query", } `; -exports[`parse-path should parse and stringify path expectedly 5`] = ` +exports[`parse-path should parse and stringify path expectedly: foo?query#hash 1`] = ` { - "hash": "#hash?query", + "hash": "#hash", "pathname": "foo", - "query": "", + "query": "?query", } `; diff --git a/test/utils/parse-path.spec.ts b/test/utils/parse-path.spec.ts index b6b7584c..febfbbdf 100644 --- a/test/utils/parse-path.spec.ts +++ b/test/utils/parse-path.spec.ts @@ -12,7 +12,7 @@ describe('parse-path', () => { for (const input of cases) { const output = parsePath(input) - expect(output).toMatchSnapshot() + expect(output).toMatchSnapshot(input) expect(stringifyPath(output)).toBe(input) } })