Skip to content

Commit 4978163

Browse files
feat: implement extensions autofix
1 parent 6e49a58 commit 4978163

File tree

1 file changed

+54
-22
lines changed

1 file changed

+54
-22
lines changed

src/rules/extensions.js

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from 'path';
2-
2+
import fs from 'fs';
33
import minimatch from 'minimatch';
44
import resolve from 'eslint-module-utils/resolve';
55
import { isBuiltIn, isExternalModule, isScoped } from '../core/importType';
@@ -91,13 +91,14 @@ function buildProperties(context) {
9191

9292
module.exports = {
9393
meta: {
94-
type: 'suggestion',
94+
type: 'problem',
9595
docs: {
96-
category: 'Style guide',
97-
description: 'Ensure consistent use of file extension within the import path.',
96+
description: 'Enforce that import statements either always include or never include allowed file extensions.',
97+
category: 'Static Analysis',
98+
recommended: false,
9899
url: docsUrl('extensions'),
99100
},
100-
101+
fixable: 'code',
101102
schema: {
102103
anyOf: [
103104
{
@@ -133,6 +134,12 @@ module.exports = {
133134
},
134135
],
135136
},
137+
messages: {
138+
missingExtension:
139+
'Missing file extension for "{{importPath}}" (expected {{expected}}).',
140+
unexpectedExtension:
141+
'Unexpected file extension "{{extension}}" in import of "{{importPath}}".',
142+
},
136143
},
137144

138145
create(context) {
@@ -151,9 +158,14 @@ module.exports = {
151158
return getModifier(extension) === 'never';
152159
}
153160

154-
function isResolvableWithoutExtension(file) {
155-
const extension = path.extname(file);
156-
const fileWithoutExtension = file.slice(0, -extension.length);
161+
// Updated: This helper now determines resolvability based on the passed options.
162+
// If the configured option for the extension is "never", we return true immediately.
163+
function isResolvableWithoutExtension(file, ext) {
164+
if (isUseOfExtensionForbidden(ext)) {
165+
return true;
166+
}
167+
const fileExt = path.extname(file);
168+
const fileWithoutExtension = file.slice(0, -fileExt.length);
157169
const resolvedFileWithoutExtension = resolve(fileWithoutExtension, context);
158170

159171
return resolvedFileWithoutExtension === resolve(file, context);
@@ -177,11 +189,19 @@ module.exports = {
177189
}
178190
}
179191

192+
function getCandidateExtension(importPath, currentDir) {
193+
const basePath = path.resolve(currentDir, importPath);
194+
const keys = Object.keys(props.pattern);
195+
const valid = keys.filter((key) => fs.existsSync(`${basePath}.${key}`));
196+
return valid.length === 1 ? `.${valid[0]}` : null;
197+
}
198+
180199
function checkFileExtension(source, node) {
181200
// bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor
182201
if (!source || !source.value) { return; }
183202

184203
const importPathWithQueryString = source.value;
204+
const currentDir = path.dirname(context.getFilename());
185205

186206
// If not undefined, the user decided if rules are enforced on this import
187207
const overrideAction = computeOverrideAction(
@@ -203,10 +223,7 @@ module.exports = {
203223
if (!overrideAction && isExternalRootModule(importPath)) { return; }
204224

205225
const resolvedPath = resolve(importPath, context);
206-
207-
// get extension from resolved path, if possible.
208-
// for unresolved, use source value.
209-
const extension = path.extname(resolvedPath || importPath).substring(1);
226+
const extensionWithDot = path.extname(resolvedPath || importPath);
210227

211228
// determine if this is a module
212229
const isPackage = isExternalModule(
@@ -215,23 +232,38 @@ module.exports = {
215232
context,
216233
) || isScoped(importPath);
217234

218-
if (!extension || !importPath.endsWith(`.${extension}`)) {
235+
// Case 1: Missing extension.
236+
if (!extensionWithDot || !importPath.endsWith(extensionWithDot)) {
219237
// ignore type-only imports and exports
220238
if (!props.checkTypeImports && (node.importKind === 'type' || node.exportKind === 'type')) { return; }
221-
const extensionRequired = isUseOfExtensionRequired(extension, !overrideAction && isPackage);
222-
const extensionForbidden = isUseOfExtensionForbidden(extension);
223-
if (extensionRequired && !extensionForbidden) {
239+
const candidate = getCandidateExtension(importPath, currentDir);
240+
if (candidate && isUseOfExtensionRequired(candidate.replace(/^\./, ''), isPackage)) {
224241
context.report({
225-
node: source,
226-
message:
227-
`Missing file extension ${extension ? `"${extension}" ` : ''}for "${importPathWithQueryString}"`,
242+
node,
243+
messageId: 'missingExtension',
244+
data: {
245+
importPath: importPathWithQueryString,
246+
expected: candidate,
247+
},
248+
fix(fixer) {
249+
return fixer.replaceText(source, JSON.stringify(importPathWithQueryString + candidate));
250+
},
228251
});
229252
}
230-
} else if (extension) {
231-
if (isUseOfExtensionForbidden(extension) && isResolvableWithoutExtension(importPath)) {
253+
} else {
254+
// Case 2: Unexpected extension provided.
255+
const extension = extensionWithDot.substring(1);
256+
if (isUseOfExtensionForbidden(extension) && isResolvableWithoutExtension(importPath, extension)) {
232257
context.report({
233258
node: source,
234-
message: `Unexpected use of file extension "${extension}" for "${importPathWithQueryString}"`,
259+
messageId: 'unexpectedExtension',
260+
data: {
261+
extension,
262+
importPath: importPathWithQueryString,
263+
},
264+
fix(fixer) {
265+
return fixer.replaceText(source, JSON.stringify(importPath.slice(0, -extensionWithDot.length)));
266+
},
235267
});
236268
}
237269
}

0 commit comments

Comments
 (0)