-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add enforceEsmExtensions option to import/extensions rule #2701
Changes from all commits
e78f561
c3747e7
ece63fb
a2b72ad
7cca3e0
9f7cbdf
e4c6dfe
dfc6d03
22ac2f4
7545f99
303cfbc
6df90d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) and manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). | ||
|
||
<!-- end auto-generated rule header --> | ||
|
||
Some file resolve algorithms allow you to omit the file extension within the import source path. For example the `node` resolver can resolve `./foo/bar` to the absolute path `/User/someone/foo/bar.js` because the `.js` extension is resolved automatically by default. Depending on the resolver you can configure more extensions to get resolved automatically. | ||
|
@@ -38,14 +40,22 @@ By providing both a string and an object, the string will set the default settin | |
|
||
For example, `["error", "never", { "svg": "always" }]` would require that all extensions are omitted, except for "svg". | ||
|
||
`ignorePackages` can be set as a separate boolean option like this: | ||
## Additional Options | ||
|
||
This rule provides additional options for configuration: | ||
|
||
- `ignorePackages` follows the same logic as setting the string value of the rule config to `ignorePackages`. Useful when using `always` as the string config. | ||
- `enforceEsmExtensions` will flag any relative import paths that do not resolve to a file. (supports --fix) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe instead of hardcoding ESM here (or java-ly capitalizing it "Esm"), this could be an option that takes always or never, and solely applies to relative paths? Meaning, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
hah.. "ESME xtensions" isn't any better ;) yea it should only apply to relative paths. I am fairly certain that I plugged it into the existing rule where that is true but let me know if I missed something. Since we're already talking about extensions an option with extensions in the name is superfluous. I'm fine with 'relativePaths', but also thought about using 'fullySpecified' similar to webpack's resolver naming: https://webpack.js.org/configuration/module/#resolvefullyspecified |
||
|
||
`ignorePackages` and `enforceEsmExtensions` can be set as a separate boolean option like this: | ||
|
||
``` | ||
"import/extensions": [ | ||
<severity>, | ||
"never" | "always" | "ignorePackages", | ||
{ | ||
ignorePackages: true | false, | ||
enforceEsmExtensions: true | false, | ||
pattern: { | ||
<extension>: "never" | "always" | "ignorePackages" | ||
} | ||
|
@@ -167,6 +177,24 @@ import express from 'express'; | |
import foo from '@/foo'; | ||
``` | ||
|
||
The following patterns are considered problems when configuration set to `['error', 'always', {ignorePackages: true, enforceEsmExtensions: true} ]`: | ||
|
||
```js | ||
import foo from './foo' | ||
import bar from './bar' | ||
import Component from './Component' | ||
import express from 'express' | ||
``` | ||
|
||
The following patterns are not considered problems when configuration set to `['error', 'always', {ignorePackages: true, enforceEsmExtensions: true} ]`: | ||
|
||
```js | ||
import foo from './foo.js' | ||
import bar from './bar/index.js' | ||
import Component from './Component.js' | ||
import express from 'express' | ||
``` | ||
|
||
## When Not To Use It | ||
|
||
If you are not concerned about a consistent usage of file extension. |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,4 +1,5 @@ | ||||||||||||||||||||||||||||||||
import path from 'path'; | ||||||||||||||||||||||||||||||||
import fs from 'fs'; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
import resolve from 'eslint-module-utils/resolve'; | ||||||||||||||||||||||||||||||||
import { isBuiltIn, isExternalModule, isScoped } from '../core/importType'; | ||||||||||||||||||||||||||||||||
|
@@ -15,6 +16,7 @@ const properties = { | |||||||||||||||||||||||||||||||
properties: { | ||||||||||||||||||||||||||||||||
'pattern': patternProperties, | ||||||||||||||||||||||||||||||||
'ignorePackages': { type: 'boolean' }, | ||||||||||||||||||||||||||||||||
'enforceEsmExtensions': { type: 'boolean' }, | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
|
@@ -24,6 +26,7 @@ function buildProperties(context) { | |||||||||||||||||||||||||||||||
defaultConfig: 'never', | ||||||||||||||||||||||||||||||||
pattern: {}, | ||||||||||||||||||||||||||||||||
ignorePackages: false, | ||||||||||||||||||||||||||||||||
enforceEsmExtensions: false, | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
context.options.forEach(obj => { | ||||||||||||||||||||||||||||||||
|
@@ -35,7 +38,7 @@ function buildProperties(context) { | |||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
// If this is not the new structure, transfer all props to result.pattern | ||||||||||||||||||||||||||||||||
if (obj.pattern === undefined && obj.ignorePackages === undefined) { | ||||||||||||||||||||||||||||||||
if (obj.pattern === undefined && obj.ignorePackages === undefined && obj.enforceEsmExtensions === undefined) { | ||||||||||||||||||||||||||||||||
Object.assign(result.pattern, obj); | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
@@ -49,6 +52,10 @@ function buildProperties(context) { | |||||||||||||||||||||||||||||||
if (obj.ignorePackages !== undefined) { | ||||||||||||||||||||||||||||||||
result.ignorePackages = obj.ignorePackages; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
// If enforceEsmExtensions is provided, transfer it to result | ||||||||||||||||||||||||||||||||
if (obj.enforceEsmExtensions !== undefined) { | ||||||||||||||||||||||||||||||||
result.enforceEsmExtensions = obj.enforceEsmExtensions; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if (result.defaultConfig === 'ignorePackages') { | ||||||||||||||||||||||||||||||||
|
@@ -59,6 +66,46 @@ function buildProperties(context) { | |||||||||||||||||||||||||||||||
return result; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
// util functions | ||||||||||||||||||||||||||||||||
const fileExists = function (filePath) { | ||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||
fs.accessSync(filePath, fs.constants.F_OK); | ||||||||||||||||||||||||||||||||
return true; | ||||||||||||||||||||||||||||||||
} catch (err) { | ||||||||||||||||||||||||||||||||
if (err && err.code) === 'ENOENT') { | ||||||||||||||||||||||||||||||||
// known and somewhat expected failure case. | ||||||||||||||||||||||||||||||||
return false; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
return false; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
const excludeParenthesisFromTokenLocation = function (token) { | ||||||||||||||||||||||||||||||||
if (token.range == null || token.loc == null) { | ||||||||||||||||||||||||||||||||
return token; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
const rangeStart = token.range[0] + 1; | ||||||||||||||||||||||||||||||||
const rangeEnd = token.range[1] - 1; | ||||||||||||||||||||||||||||||||
const locColStart = token.loc.start.column + 1; | ||||||||||||||||||||||||||||||||
const locColEnd = token.loc.end.column - 1; | ||||||||||||||||||||||||||||||||
const newToken = { | ||||||||||||||||||||||||||||||||
...token, | ||||||||||||||||||||||||||||||||
range: [rangeStart, rangeEnd], | ||||||||||||||||||||||||||||||||
loc: { | ||||||||||||||||||||||||||||||||
start: { ...token.loc.start, column: locColStart }, | ||||||||||||||||||||||||||||||||
end: { ...token.loc.end, column: locColEnd }, | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
return newToken; | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
const getEsmImportFixer = function (tokenLiteral, updated) { | ||||||||||||||||||||||||||||||||
return function (fixer) { | ||||||||||||||||||||||||||||||||
return fixer.replaceText(excludeParenthesisFromTokenLocation(tokenLiteral), updated); | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
module.exports = { | ||||||||||||||||||||||||||||||||
meta: { | ||||||||||||||||||||||||||||||||
type: 'suggestion', | ||||||||||||||||||||||||||||||||
|
@@ -67,7 +114,7 @@ module.exports = { | |||||||||||||||||||||||||||||||
description: 'Ensure consistent use of file extension within the import path.', | ||||||||||||||||||||||||||||||||
url: docsUrl('extensions'), | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
fixable: 'code', | ||||||||||||||||||||||||||||||||
schema: { | ||||||||||||||||||||||||||||||||
anyOf: [ | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
|
@@ -103,6 +150,8 @@ module.exports = { | |||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
], | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
hasSuggestions: true, | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
create(context) { | ||||||||||||||||||||||||||||||||
|
@@ -114,13 +163,17 @@ module.exports = { | |||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
function isUseOfExtensionRequired(extension, isPackage) { | ||||||||||||||||||||||||||||||||
return getModifier(extension) === 'always' && (!props.ignorePackages || !isPackage); | ||||||||||||||||||||||||||||||||
return getModifier(extension) === 'always' && ((!props.ignorePackages || !isPackage) || props.enforceEsmExtensions) ; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
function isUseOfExtensionForbidden(extension) { | ||||||||||||||||||||||||||||||||
return getModifier(extension) === 'never'; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
function isUseOfEsmImportsEnforced() { | ||||||||||||||||||||||||||||||||
return props.enforceEsmExtensions === true; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
function isResolvableWithoutExtension(file) { | ||||||||||||||||||||||||||||||||
const extension = path.extname(file); | ||||||||||||||||||||||||||||||||
const fileWithoutExtension = file.slice(0, -extension.length); | ||||||||||||||||||||||||||||||||
|
@@ -137,6 +190,56 @@ module.exports = { | |||||||||||||||||||||||||||||||
return false; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
function getEsmExtensionReport(node) { | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
const esmExtensions = ['.js', '.ts', '.mjs', '.cjs']; | ||||||||||||||||||||||||||||||||
esmExtensions.push(...esmExtensions.map((ext) => `/index${ext}`)); | ||||||||||||||||||||||||||||||||
Comment on lines
+194
to
+196
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
the also, the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your code isn't valid, but I can remove the 'ts' extension w.r.t typescript extensions, here's the gotcha: in Typescript packages, you have to For this option to be robust in the current landscape, we should really accept a mapping of expected fileExtensions, and the extension to use in the source. something like: {
"import/extensions": ["error", "always", {
"esm": {
"extensionOverride": {
"ts": "js",
"tsx": "jsx"
},
"extensions": ["js", "ts", "cjs", "mjs"]
}}]
} where extensions without overrides are replaced with themselves. What do you think about this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think for this rule, we can assume when someone enables it that they are using ESM for their non maybe changing the name to 'fullyResolved' would help communicate more accurately what it's fixing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. relevant issue: microsoft/TypeScript#49083 (comment) allowing overrides would let the consumers leave 'ts' replacements as 'ts' (deno migrations) or swapping ts for js (esm migration in typescript project) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is invalid about that code? it works perfectly fine. I understand about the TS-specific quirks but that should come from the TS resolver and not be hardcoded into a rule that might not be running on TS at all. I do not think we should ever assume that someone is using type:module, which is harmful and should be avoided anyways (altho i believe TS's implementation requires it, not everyone uses TS). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rofl ok fair enough, good catch. in that case, let's keep it as is, but still without TS.
Suggested change
however we'll still need to handle that Unless this is "extensions that might exist in ESM files", and then i'm not sure why it wouldn't include wasm and json? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, thats why im thinking we should change the rule to "fullyResolved" and then allow users to indicate the extensions they care about, as well as whatever mapping they need. For my usecase (typescript) i have to check for the existence of both .js and .ts, and replace both with .js What do you think of that? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tbh this really seems like a uniquely typescript use case, and it probably should live in the TS eslint plugin (cc @bradzacher). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The philosophy of TS is and always has been that you write the import paths that you want at runtime. TS purposely does not transform import paths at all - it will always emit the exact same string after transpilation. In fact currently it is an error to attempt to import a file using Because of this, when they added ESM support they added module resolution logic in the typechecker so that when TS sees TS 5.0 includes a flag which makes it so that To summarise:
I think this is the priority order of how TS looks up extensions in the ambiguous cases:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bradzacher ok, so i'm not really sure what that means here. It really seems like it should be an option to the TS resolver, not to any particular rule, but it's pretty complex :-) |
||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
const importSource = node.source; | ||||||||||||||||||||||||||||||||
const importedPath = importSource.value; | ||||||||||||||||||||||||||||||||
const cwd = context.getCwd(); | ||||||||||||||||||||||||||||||||
const filename = context.getFilename(); | ||||||||||||||||||||||||||||||||
const relativeFilePath = path.relative(cwd, filename); | ||||||||||||||||||||||||||||||||
const relativeSourceFileDir = path.dirname(relativeFilePath); | ||||||||||||||||||||||||||||||||
const absoluteSourceFileDir = path.resolve(cwd, relativeSourceFileDir); | ||||||||||||||||||||||||||||||||
const importedFileAbsolutePath = path.resolve(absoluteSourceFileDir, importedPath); | ||||||||||||||||||||||||||||||||
const importOrExportLabel = node.type.match(/import/i) != null ? 'import of' : 'export from'; | ||||||||||||||||||||||||||||||||
let correctImportPath = null; | ||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||
for (let i = 0; i < esmExtensions.length; i++) { | ||||||||||||||||||||||||||||||||
const ext = esmExtensions[i]; | ||||||||||||||||||||||||||||||||
const potentialImportPath = `${importedFileAbsolutePath}${ext}`; | ||||||||||||||||||||||||||||||||
if (fileExists(potentialImportPath, context)) { | ||||||||||||||||||||||||||||||||
correctImportPath = importedPath + ext; | ||||||||||||||||||||||||||||||||
break; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} catch (err) { | ||||||||||||||||||||||||||||||||
return null; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
if (correctImportPath == null) { | ||||||||||||||||||||||||||||||||
return null; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if (correctImportPath.match(/^\./) == null) { | ||||||||||||||||||||||||||||||||
correctImportPath = `./${correctImportPath}`; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
const suggestionDesc = `Use "${correctImportPath}" instead.`; | ||||||||||||||||||||||||||||||||
const fix = getEsmImportFixer(node.source, correctImportPath); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
return { | ||||||||||||||||||||||||||||||||
message: `Invalid ESM ${importOrExportLabel} "${importedPath}". ${suggestionDesc}`, | ||||||||||||||||||||||||||||||||
node, | ||||||||||||||||||||||||||||||||
suggest: [ | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
desc: suggestionDesc, | ||||||||||||||||||||||||||||||||
fix, | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
], | ||||||||||||||||||||||||||||||||
fix, | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
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; | ||||||||||||||||||||||||||||||||
|
@@ -171,11 +274,16 @@ module.exports = { | |||||||||||||||||||||||||||||||
const extensionRequired = isUseOfExtensionRequired(extension, isPackage); | ||||||||||||||||||||||||||||||||
const extensionForbidden = isUseOfExtensionForbidden(extension); | ||||||||||||||||||||||||||||||||
if (extensionRequired && !extensionForbidden) { | ||||||||||||||||||||||||||||||||
context.report({ | ||||||||||||||||||||||||||||||||
node: source, | ||||||||||||||||||||||||||||||||
message: | ||||||||||||||||||||||||||||||||
`Missing file extension ${extension ? `"${extension}" ` : ''}for "${importPathWithQueryString}"`, | ||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||
const esmExtensionsReport = isUseOfEsmImportsEnforced() ? getEsmExtensionReport(node) : null; | ||||||||||||||||||||||||||||||||
if (esmExtensionsReport != null) { | ||||||||||||||||||||||||||||||||
context.report(esmExtensionsReport); | ||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||
context.report({ | ||||||||||||||||||||||||||||||||
node: source, | ||||||||||||||||||||||||||||||||
message: | ||||||||||||||||||||||||||||||||
`Missing file extension ${extension ? `"${extension}" ` : ''}for "${importPathWithQueryString}"`, | ||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} else if (extension) { | ||||||||||||||||||||||||||||||||
if (isUseOfExtensionForbidden(extension) && isResolvableWithoutExtension(importPath)) { | ||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we're going to make it autofixable, let's make sure it can autofix everything, not just this new option?
it might be nice to have a separate PR that makes the rule autofixable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I saw from adding the tests for this option is that all the "valid" usecases where imports are fixed will need updating, so the PR could quickly become very large.
I would much rather open a separate PR for autofixing everything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to be fair though, many of the failures don't have valid fixes. i.e. import not found... how do we want to handle those? remove the import?