Skip to content

Commit a3e0e29

Browse files
committedMay 2, 2019
✨ add node/file-extension-in-import rule
1 parent a3a6e41 commit a3e0e29

14 files changed

+481
-9
lines changed
 

‎README.md

+1
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")` | |
+110
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

+1
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"),

‎lib/rules/file-extension-in-import.js

+126
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

+3-1
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

+6-4
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

+2-2
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

+2-2
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.

‎tests/fixtures/file-extension-in-import/c.mjs

Whitespace-only changes.

‎tests/fixtures/file-extension-in-import/multi.cjs

Whitespace-only changes.

‎tests/fixtures/file-extension-in-import/multi.mjs

Whitespace-only changes.
+230
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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 RuleTester = require("eslint").RuleTester
9+
const rule = require("../../../lib/rules/file-extension-in-import")
10+
11+
function fixture(filename) {
12+
return path.resolve(
13+
__dirname,
14+
"../../fixtures/file-extension-in-import",
15+
filename
16+
)
17+
}
18+
19+
new RuleTester({
20+
parserOptions: {
21+
ecmaVersion: 2015,
22+
sourceType: "module",
23+
},
24+
settings: {
25+
node: {
26+
tryExtensions: [".mjs", ".cjs", ".js", ".json", ".node"],
27+
},
28+
},
29+
}).run("file-extension-in-import", rule, {
30+
valid: [
31+
{
32+
filename: fixture("test.js"),
33+
code: "import 'eslint'",
34+
},
35+
{
36+
filename: fixture("test.js"),
37+
code: "import 'xxx'",
38+
},
39+
{
40+
filename: fixture("test.js"),
41+
code: "import './a.js'",
42+
},
43+
{
44+
filename: fixture("test.js"),
45+
code: "import './b.json'",
46+
},
47+
{
48+
filename: fixture("test.js"),
49+
code: "import './c.mjs'",
50+
},
51+
{
52+
filename: fixture("test.js"),
53+
code: "import './a.js'",
54+
options: ["always"],
55+
},
56+
{
57+
filename: fixture("test.js"),
58+
code: "import './b.json'",
59+
options: ["always"],
60+
},
61+
{
62+
filename: fixture("test.js"),
63+
code: "import './c.mjs'",
64+
options: ["always"],
65+
},
66+
{
67+
filename: fixture("test.js"),
68+
code: "import './a'",
69+
options: ["never"],
70+
},
71+
{
72+
filename: fixture("test.js"),
73+
code: "import './b'",
74+
options: ["never"],
75+
},
76+
{
77+
filename: fixture("test.js"),
78+
code: "import './c'",
79+
options: ["never"],
80+
},
81+
{
82+
filename: fixture("test.js"),
83+
code: "import './a'",
84+
options: ["always", { ".js": "never" }],
85+
},
86+
{
87+
filename: fixture("test.js"),
88+
code: "import './b.json'",
89+
options: ["always", { ".js": "never" }],
90+
},
91+
{
92+
filename: fixture("test.js"),
93+
code: "import './c.mjs'",
94+
options: ["always", { ".js": "never" }],
95+
},
96+
{
97+
filename: fixture("test.js"),
98+
code: "import './a'",
99+
options: ["never", { ".json": "always" }],
100+
},
101+
{
102+
filename: fixture("test.js"),
103+
code: "import './b.json'",
104+
options: ["never", { ".json": "always" }],
105+
},
106+
{
107+
filename: fixture("test.js"),
108+
code: "import './c'",
109+
options: ["never", { ".json": "always" }],
110+
},
111+
],
112+
invalid: [
113+
{
114+
filename: fixture("test.js"),
115+
code: "import './a'",
116+
output: "import './a.js'",
117+
errors: [{ messageId: "requireExt", data: { ext: ".js" } }],
118+
},
119+
{
120+
filename: fixture("test.js"),
121+
code: "import './b'",
122+
output: "import './b.json'",
123+
errors: [{ messageId: "requireExt", data: { ext: ".json" } }],
124+
},
125+
{
126+
filename: fixture("test.js"),
127+
code: "import './c'",
128+
output: "import './c.mjs'",
129+
errors: [{ messageId: "requireExt", data: { ext: ".mjs" } }],
130+
},
131+
{
132+
filename: fixture("test.js"),
133+
code: "import './a'",
134+
output: "import './a.js'",
135+
options: ["always"],
136+
errors: [{ messageId: "requireExt", data: { ext: ".js" } }],
137+
},
138+
{
139+
filename: fixture("test.js"),
140+
code: "import './b'",
141+
output: "import './b.json'",
142+
options: ["always"],
143+
errors: [{ messageId: "requireExt", data: { ext: ".json" } }],
144+
},
145+
{
146+
filename: fixture("test.js"),
147+
code: "import './c'",
148+
output: "import './c.mjs'",
149+
options: ["always"],
150+
errors: [{ messageId: "requireExt", data: { ext: ".mjs" } }],
151+
},
152+
{
153+
filename: fixture("test.js"),
154+
code: "import './a.js'",
155+
output: "import './a'",
156+
options: ["never"],
157+
errors: [{ messageId: "forbidExt", data: { ext: ".js" } }],
158+
},
159+
{
160+
filename: fixture("test.js"),
161+
code: "import './b.json'",
162+
output: "import './b'",
163+
options: ["never"],
164+
errors: [{ messageId: "forbidExt", data: { ext: ".json" } }],
165+
},
166+
{
167+
filename: fixture("test.js"),
168+
code: "import './c.mjs'",
169+
output: "import './c'",
170+
options: ["never"],
171+
errors: [{ messageId: "forbidExt", data: { ext: ".mjs" } }],
172+
},
173+
{
174+
filename: fixture("test.js"),
175+
code: "import './a.js'",
176+
output: "import './a'",
177+
options: ["always", { ".js": "never" }],
178+
errors: [{ messageId: "forbidExt", data: { ext: ".js" } }],
179+
},
180+
{
181+
filename: fixture("test.js"),
182+
code: "import './b'",
183+
output: "import './b.json'",
184+
options: ["always", { ".js": "never" }],
185+
errors: [{ messageId: "requireExt", data: { ext: ".json" } }],
186+
},
187+
{
188+
filename: fixture("test.js"),
189+
code: "import './c'",
190+
output: "import './c.mjs'",
191+
options: ["always", { ".js": "never" }],
192+
errors: [{ messageId: "requireExt", data: { ext: ".mjs" } }],
193+
},
194+
{
195+
filename: fixture("test.js"),
196+
code: "import './a.js'",
197+
output: "import './a'",
198+
options: ["never", { ".json": "always" }],
199+
errors: [{ messageId: "forbidExt", data: { ext: ".js" } }],
200+
},
201+
{
202+
filename: fixture("test.js"),
203+
code: "import './b'",
204+
output: "import './b.json'",
205+
options: ["never", { ".json": "always" }],
206+
errors: [{ messageId: "requireExt", data: { ext: ".json" } }],
207+
},
208+
{
209+
filename: fixture("test.js"),
210+
code: "import './c.mjs'",
211+
output: "import './c'",
212+
options: ["never", { ".json": "always" }],
213+
errors: [{ messageId: "forbidExt", data: { ext: ".mjs" } }],
214+
},
215+
{
216+
filename: fixture("test.js"),
217+
code: "import './multi'",
218+
output: null,
219+
options: ["always"],
220+
errors: [{ messageId: "requireExt", data: { ext: ".mjs" } }],
221+
},
222+
{
223+
filename: fixture("test.js"),
224+
code: "import './multi.cjs'",
225+
output: null,
226+
options: ["never"],
227+
errors: [{ messageId: "forbidExt", data: { ext: ".cjs" } }],
228+
},
229+
],
230+
})

0 commit comments

Comments
 (0)
Please sign in to comment.