Skip to content

Commit a3e0e29

Browse files
committed
✨ add node/file-extension-in-import rule
1 parent a3a6e41 commit a3e0e29

File tree

14 files changed

+481
-9
lines changed

14 files changed

+481
-9
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ $ npm install --save-dev eslint eslint-plugin-node
8484
| Rule ID | Description | |
8585
|:--------|:------------|:--:|
8686
| [node/exports-style](./docs/rules/exports-style.md) | enforce either `module.exports` or `exports` | |
87+
| [node/file-extension-in-import](./docs/rules/file-extension-in-import.md) | enforce the style of file extensions in `import` declarations | ✒️ |
8788
| [node/prefer-global/buffer](./docs/rules/prefer-global/buffer.md) | enforce either `Buffer` or `require("buffer").Buffer` | |
8889
| [node/prefer-global/console](./docs/rules/prefer-global/console.md) | enforce either `console` or `require("console")` | |
8990
| [node/prefer-global/process](./docs/rules/prefer-global/process.md) | enforce either `process` or `require("process")` | |
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# enforce the style of file extensions in `import` declarations (file-extension-in-import)
2+
3+
We can omit file extensions in `import`/`export` declarations.
4+
5+
```js
6+
import foo from "./path/to/a/file" // maybe it's resolved to 'file.js' or 'file.json'
7+
export * from "./path/to/a/file"
8+
```
9+
10+
However, [--experimental-modules](https://medium.com/@nodejs/announcing-a-new-experimental-modules-1be8d2d6c2ff) has declared to drop the file extension omition.
11+
12+
Also, we can import a variety kind of files with bundlers such as Webpack. In the situation, probably explicit file extensions help us to understand code.
13+
14+
## Rule Details
15+
16+
This rule enforces the style of file extensions in `import`/`export` declarations.
17+
18+
## Options
19+
20+
This rule has a string option and an object option.
21+
22+
```json
23+
{
24+
"node/file-extension-in-import": [
25+
"error",
26+
"always" or "never",
27+
{
28+
"tryExtensions": [".js", ".json", ".node"],
29+
".xxx": "always" or "never",
30+
}
31+
]
32+
}
33+
```
34+
35+
- `"always"` (default) requires file extensions in `import`/`export` declarations.
36+
- `"never"` disallows file extensions in `import`/`export` declarations.
37+
- `tryExtensions` is the file extensions to resolve import paths. Default is `[".js", ".json", ".node"]`.
38+
- `.xxx` is the overriding setting for specific file extensions. You can use arbitrary property names which start with `.`.
39+
40+
### always
41+
42+
Examples of :-1: **incorrect** code for the `"always"` option:
43+
44+
```js
45+
/*eslint node/file-extension-in-import: ["error", "always"]*/
46+
47+
import foo from "./path/to/a/file"
48+
```
49+
50+
Examples of :+1: **correct** code for the `"always"` option:
51+
52+
```js
53+
/*eslint node/file-extension-in-import: ["error", "always"]*/
54+
55+
import eslint from "eslint"
56+
import foo from "./path/to/a/file.js"
57+
```
58+
59+
### never
60+
61+
Examples of :-1: **incorrect** code for the `"never"` option:
62+
63+
```js
64+
/*eslint node/file-extension-in-import: ["error", "never"]*/
65+
66+
import foo from "./path/to/a/file.js"
67+
```
68+
69+
Examples of :+1: **correct** code for the `"never"` option:
70+
71+
```js
72+
/*eslint node/file-extension-in-import: ["error", "never"]*/
73+
74+
import eslint from "eslint"
75+
import foo from "./path/to/a/file"
76+
```
77+
78+
### .xxx
79+
80+
Examples of :+1: **correct** code for the `["always", { ".js": "never" }]` option:
81+
82+
```js
83+
/*eslint node/file-extension-in-import: ["error", "always", { ".js": "never" }]*/
84+
85+
import eslint from "eslint"
86+
import script from "./script"
87+
import styles from "./styles.css"
88+
import logo from "./logo.png"
89+
```
90+
91+
## Shared Settings
92+
93+
The following options can be set by [shared settings](http://eslint.org/docs/user-guide/configuring.html#adding-shared-settings).
94+
Several rules have the same option, but we can set this option at once.
95+
96+
- `tryExtensions`
97+
98+
```js
99+
// .eslintrc.js
100+
module.exports = {
101+
"settings": {
102+
"node": {
103+
"tryExtensions": [".js", ".json", ".node"]
104+
}
105+
},
106+
"rules": {
107+
"node/file-extension-in-import": "error"
108+
}
109+
}
110+
```

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
},
1212
rules: {
1313
"exports-style": require("./rules/exports-style"),
14+
"file-extension-in-import": require("./rules/file-extension-in-import"),
1415
"no-deprecated-api": require("./rules/no-deprecated-api"),
1516
"no-extraneous-import": require("./rules/no-extraneous-import"),
1617
"no-extraneous-require": require("./rules/no-extraneous-require"),
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* @author Toru Nagashima
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
"use strict"
6+
7+
const path = require("path")
8+
const fs = require("fs")
9+
const getImportExportTargets = require("../util/get-import-export-targets")
10+
const getTryExtensions = require("../util/get-try-extensions")
11+
12+
/**
13+
* Get all file extensions of the files which have the same basename.
14+
* @param {string} filePath The path to the original file to check.
15+
* @returns {string[]} File extensions.
16+
*/
17+
function getExistingExtensions(filePath) {
18+
const basename = path.basename(filePath, path.extname(filePath))
19+
try {
20+
return fs
21+
.readdirSync(path.dirname(filePath))
22+
.filter(
23+
filename =>
24+
path.basename(filename, path.extname(filename)) === basename
25+
)
26+
.map(filename => path.extname(filename))
27+
} catch (_error) {
28+
return []
29+
}
30+
}
31+
32+
module.exports = {
33+
meta: {
34+
docs: {
35+
description:
36+
"enforce the style of file extensions in `import` declarations",
37+
category: "Stylistic Issues",
38+
recommended: false,
39+
url:
40+
"https://github.com/mysticatea/eslint-plugin-node/blob/v8.0.1/docs/rules/file-extension-in-import.md",
41+
},
42+
fixable: "code",
43+
messages: {
44+
requireExt: "require file extension '{{ext}}'.",
45+
forbidExt: "forbid file extension '{{ext}}'.",
46+
},
47+
schema: [
48+
{
49+
enum: ["always", "never"],
50+
},
51+
{
52+
type: "object",
53+
properties: {
54+
tryExtensions: getTryExtensions.schema,
55+
},
56+
additionalProperties: {
57+
enum: ["always", "never"],
58+
},
59+
},
60+
],
61+
type: "suggestion",
62+
},
63+
create(context) {
64+
if (context.getFilename().startsWith("<")) {
65+
return {}
66+
}
67+
const defaultStyle = context.options[0] || "always"
68+
const overrideStyle = context.options[1] || {}
69+
70+
function verify({ filePath, name, node }) {
71+
// Ignore if it's not resolved to a file or it's a bare module.
72+
if (!filePath || !/[/\\]/u.test(name)) {
73+
return
74+
}
75+
76+
// Get extension.
77+
const originalExt = path.extname(name)
78+
const resolvedExt = path.extname(filePath)
79+
const existingExts = getExistingExtensions(filePath)
80+
if (!resolvedExt && existingExts.length !== 1) {
81+
// Ignore if the file extension could not be determined one.
82+
return
83+
}
84+
const ext = resolvedExt || existingExts[0]
85+
const style = overrideStyle[ext] || defaultStyle
86+
87+
// Verify.
88+
if (style === "always" && ext !== originalExt) {
89+
context.report({
90+
node,
91+
messageId: "requireExt",
92+
data: { ext },
93+
fix(fixer) {
94+
if (existingExts.length !== 1) {
95+
return null
96+
}
97+
const index = node.range[1] - 1
98+
return fixer.insertTextBeforeRange([index, index], ext)
99+
},
100+
})
101+
} else if (style === "never" && ext === originalExt) {
102+
context.report({
103+
node,
104+
messageId: "forbidExt",
105+
data: { ext },
106+
fix(fixer) {
107+
if (existingExts.length !== 1) {
108+
return null
109+
}
110+
const index = name.lastIndexOf(ext)
111+
const start = node.range[0] + 1 + index
112+
const end = start + ext.length
113+
return fixer.removeRange([start, end])
114+
},
115+
})
116+
}
117+
}
118+
119+
return {
120+
"Program:exit"(node) {
121+
const opts = { optionIndex: 1 }
122+
getImportExportTargets(context, node, opts).forEach(verify)
123+
},
124+
}
125+
},
126+
}

lib/rules/no-hide-core-modules.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ module.exports = {
104104
const targets = []
105105
.concat(
106106
getRequireTargets(context, true),
107-
getImportExportTargets(context, node, true)
107+
getImportExportTargets(context, node, {
108+
includeCore: true,
109+
})
108110
)
109111
.filter(t => CORE_MODULES.has(t.moduleName))
110112

lib/util/get-import-export-targets.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,20 @@ const MODULE_TYPE = /^(?:Import|Export(?:Named|Default|All))Declaration$/u
2020
*
2121
* @param {RuleContext} context - The rule context.
2222
* @param {ASTNode} programNode - The node of Program.
23-
* @param {boolean} includeCore - The flag to include core modules.
23+
* @param {Object} [options] - The flag to include core modules.
24+
* @param {boolean} [options.includeCore] - The flag to include core modules.
25+
* @param {number} [options.optionIndex] - The index of rule options.
2426
* @returns {ImportTarget[]} A list of found target's information.
2527
*/
2628
module.exports = function getImportExportTargets(
2729
context,
2830
programNode,
29-
includeCore
31+
{ includeCore = false, optionIndex = 0 } = {}
3032
) {
3133
const retv = []
3234
const basedir = path.dirname(path.resolve(context.getFilename()))
33-
const paths = getResolvePaths(context)
34-
const extensions = getTryExtensions(context)
35+
const paths = getResolvePaths(context, optionIndex)
36+
const extensions = getTryExtensions(context, optionIndex)
3537
const options = { basedir, paths, extensions }
3638

3739
for (const statement of programNode.body) {

lib/util/get-resolve-paths.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ function get(option) {
2929
* @param {RuleContext} context - The rule context.
3030
* @returns {string[]} A list of extensions.
3131
*/
32-
module.exports = function getResolvePaths(context) {
32+
module.exports = function getResolvePaths(context, optionIndex = 0) {
3333
return (
34-
get(context.options && context.options[0]) ||
34+
get(context.options && context.options[optionIndex]) ||
3535
get(context.settings && context.settings.node) ||
3636
DEFAULT_VALUE
3737
)

lib/util/get-try-extensions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ function get(option) {
2929
* @param {RuleContext} context - The rule context.
3030
* @returns {string[]} A list of extensions.
3131
*/
32-
module.exports = function getTryExtensions(context) {
32+
module.exports = function getTryExtensions(context, optionIndex = 0) {
3333
return (
34-
get(context.options && context.options[0]) ||
34+
get(context.options && context.options[optionIndex]) ||
3535
get(context.settings && context.settings.node) ||
3636
DEFAULT_VALUE
3737
)

tests/fixtures/file-extension-in-import/a.js

Whitespace-only changes.

tests/fixtures/file-extension-in-import/b.json

Whitespace-only changes.

0 commit comments

Comments
 (0)