From 8f6185d077971100a22abbc1c3b0631df9bc1f4c Mon Sep 17 00:00:00 2001 From: Stephen Jason Wang Date: Thu, 10 Apr 2025 01:45:45 +0800 Subject: [PATCH 1/3] feat(extensions): add autofix support for consistent file extensions in import paths --- README.md | 2 +- docs/rules/extensions.md | 2 ++ src/rules/extensions.js | 27 ++++++++++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 885f34873c..735cf3b689 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a | [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | | | [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | | | [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | | -| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | | | | +| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | 🔧 | | | | [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | | | [group-exports](docs/rules/group-exports.md) | Prefer named exports to be grouped together in a single export declaration | | | | | | | | [imports-first](docs/rules/imports-first.md) | Replaced by `import/first`. | | | | 🔧 | | ❌ | diff --git a/docs/rules/extensions.md b/docs/rules/extensions.md index bd9f3f3584..901d36c0cf 100644 --- a/docs/rules/extensions.md +++ b/docs/rules/extensions.md @@ -1,5 +1,7 @@ # import/extensions +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + 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. diff --git a/src/rules/extensions.js b/src/rules/extensions.js index 2aeef64758..9c03d229bb 100644 --- a/src/rules/extensions.js +++ b/src/rules/extensions.js @@ -46,6 +46,7 @@ function buildProperties(context) { defaultConfig: 'never', pattern: {}, ignorePackages: false, + fix: false, }; context.options.forEach((obj) => { @@ -72,6 +73,11 @@ function buildProperties(context) { result.ignorePackages = obj.ignorePackages; } + // If fix is provided, transfer it to result + if (obj.fix !== undefined) { + result.fix = obj.fix; + } + if (obj.checkTypeImports !== undefined) { result.checkTypeImports = obj.checkTypeImports; } @@ -97,7 +103,7 @@ module.exports = { description: 'Ensure consistent use of file extension within the import path.', url: docsUrl('extensions'), }, - + fixable: 'code', schema: { anyOf: [ { @@ -225,6 +231,14 @@ module.exports = { node: source, message: `Missing file extension ${extension ? `"${extension}" ` : ''}for "${importPathWithQueryString}"`, + ...props.fix && extension ? { + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify(`${importPathWithQueryString}.${extension}`), + ); + }, + } : {}, }); } } else if (extension) { @@ -232,6 +246,17 @@ module.exports = { context.report({ node: source, message: `Unexpected use of file extension "${extension}" for "${importPathWithQueryString}"`, + ...props.fix + ? { + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify( + importPath.slice(0, -(extension.length + 1)), + ), + ); + }, + } : {}, }); } } From b8e73709316cd6caf8f40444abc537606c5b2e17 Mon Sep 17 00:00:00 2001 From: Stephen Jason Wang Date: Thu, 10 Apr 2025 23:42:04 +0800 Subject: [PATCH 2/3] test: add test cases for extensions rule with fix option --- tests/src/rules/extensions.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/src/rules/extensions.js b/tests/src/rules/extensions.js index 8843713e34..0ac1a59688 100644 --- a/tests/src/rules/extensions.js +++ b/tests/src/rules/extensions.js @@ -155,6 +155,16 @@ ruleTester.run('extensions', rule, { ].join('\n'), options: ['always'], }), + + test({ + code: "import foo from './foo';", + options: [{ fix: true }], + }), + + test({ + code: "import foo from './foo.js';", + options: [{ fix: true, pattern: { js: 'always' } }], + }), ], invalid: [ @@ -652,6 +662,13 @@ ruleTester.run('extensions', rule, { }, ], }), + + test({ + code: 'import foo from "./foo.js";', + options: ['always', { pattern: { js: 'never' }, fix: true }], + errors: [{ message: 'Unexpected use of file extension "js" for "./foo.js"' }], + output: 'import foo from "./foo";', + }), ], }); From 8cd047bfd5f25dc49de7582823d38c2a8995a908 Mon Sep 17 00:00:00 2001 From: Stephen Jason Wang Date: Sun, 24 Aug 2025 22:49:37 +0800 Subject: [PATCH 3/3] feat(extensions): implement import path replacement for consistent file extensions --- src/rules/extensions.js | 59 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/rules/extensions.js b/src/rules/extensions.js index 9c03d229bb..a600f538b3 100644 --- a/src/rules/extensions.js +++ b/src/rules/extensions.js @@ -183,6 +183,33 @@ module.exports = { } } + function replaceImportPath(source, importPath) { + return source.replace( + /^(['"])(.+)\1$/, + (_, quote) => `${quote}${importPath}${quote}`, + ) + } + + const parsePath = (path) => { + const hashIndex = path.indexOf('#') + const queryIndex = path.indexOf('?') + const hasHash = hashIndex !== -1 + const hash = hasHash ? path.slice(hashIndex) : '' + const hasQuery = queryIndex !== -1 && (!hasHash || queryIndex < hashIndex) + const query = hasQuery + ? path.slice(queryIndex, hasHash ? hashIndex : undefined) + : '' + const pathname = hasQuery + ? path.slice(0, queryIndex) + : hasHash + ? path.slice(0, hashIndex) + : path + return { pathname, query, hash } + } + + const stringifyPath = ({ pathname, query, hash }) => + pathname + query + hash + function checkFileExtension(source, node) { // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor if (!source || !source.value) { return; } @@ -202,7 +229,11 @@ module.exports = { // don't enforce anything on builtins if (!overrideAction && isBuiltIn(importPathWithQueryString, context.settings)) { 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`) @@ -227,6 +258,19 @@ module.exports = { const extensionRequired = isUseOfExtensionRequired(extension, !overrideAction && isPackage); const extensionForbidden = isUseOfExtensionForbidden(extension); if (extensionRequired && !extensionForbidden) { + const fixedImportPath = stringifyPath({ + pathname: `${ + /([\\/]|[\\/]?\.?\.)$/.test(importPath) + ? `${ + importPath.endsWith('/') + ? importPath.slice(0, -1) + : importPath + }/index.${extension}` + : `${importPath}.${extension}` + }`, + query, + hash, + }) context.report({ node: source, message: @@ -235,7 +279,7 @@ module.exports = { fix(fixer) { return fixer.replaceText( source, - JSON.stringify(`${importPathWithQueryString}.${extension}`), + replaceImportPath(source.raw, fixedImportPath), ); }, } : {}, @@ -251,9 +295,14 @@ module.exports = { fix(fixer) { return fixer.replaceText( source, - JSON.stringify( - importPath.slice(0, -(extension.length + 1)), - ), + replaceImportPath( + source.raw, + stringifyPath({ + pathname: importPath.slice(0, -(extension.length + 1)), + query, + hash, + }), + ), ); }, } : {},