Skip to content

Commit 88d1c37

Browse files
authored
feat: Add n/prefer-node-protocol rule (#183)
* feat: add `n/prefer-node-protocol` rule * feat: support `require` function * docs: add `export` examples * feat: enable or disable this rule by supported Node.js version * refactor: use `visit-require` and `visit-import` * fix: avoid type error by non-string types * refactor: use `moduleStyle` for simplicity * chore: update to false for avoiding a breaking change
1 parent 9930101 commit 88d1c37

File tree

6 files changed

+447
-1
lines changed

6 files changed

+447
-1
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
143143
| [prefer-global/text-encoder](docs/rules/prefer-global/text-encoder.md) | enforce either `TextEncoder` or `require("util").TextEncoder` | | | |
144144
| [prefer-global/url](docs/rules/prefer-global/url.md) | enforce either `URL` or `require("url").URL` | | | |
145145
| [prefer-global/url-search-params](docs/rules/prefer-global/url-search-params.md) | enforce either `URLSearchParams` or `require("url").URLSearchParams` | | | |
146+
| [prefer-node-protocol](docs/rules/prefer-node-protocol.md) | enforce using the `node:` protocol when importing Node.js builtin modules. | | 🔧 | |
146147
| [prefer-promises/dns](docs/rules/prefer-promises/dns.md) | enforce `require("dns").promises` | | | |
147148
| [prefer-promises/fs](docs/rules/prefer-promises/fs.md) | enforce `require("fs").promises` | | | |
148149
| [process-exit-as-throw](docs/rules/process-exit-as-throw.md) | require that `process.exit()` expressions use the same code path as `throw` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |

docs/rules/prefer-node-protocol.md

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Enforce using the `node:` protocol when importing Node.js builtin modules (`n/prefer-node-protocol`)
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Older built-in Node modules such as fs now can be imported via either their name or `node:` + their name:
8+
9+
```js
10+
import fs from "fs"
11+
import fs from "node:fs"
12+
```
13+
14+
The prefixed versions are nice because they can't be overridden by user modules and are similarly formatted to prefix-only modules such as node:test.
15+
16+
Note that Node.js support for this feature began in:
17+
18+
> v16.0.0, v14.18.0 (`require()`)
19+
> v14.13.1, v12.20.0 (`import`)
20+
21+
## 📖 Rule Details
22+
23+
This rule enforces that `node:` protocol is prepended to built-in Node modules when importing or exporting built-in Node modules.
24+
25+
👍 Examples of **correct** code for this rule:
26+
27+
```js
28+
/*eslint n/prefer-node-protocol: error */
29+
30+
import fs from "node:fs"
31+
32+
export { promises } from "node:fs"
33+
34+
const fs = require("node:fs")
35+
```
36+
37+
👎 Examples of **incorrect** code for this rule:
38+
39+
```js
40+
/*eslint n/prefer-node-protocol: error */
41+
42+
import fs from "fs"
43+
44+
export { promises } from "fs"
45+
46+
const fs = require("fs")
47+
```
48+
49+
### Configured Node.js version range
50+
51+
[Configured Node.js version range](../../../README.md#configured-nodejs-version-range)
52+
53+
### Options
54+
55+
```json
56+
{
57+
"n/prefer-node-protocol": ["error", {
58+
"version": ">=16.0.0",
59+
}]
60+
}
61+
```
62+
63+
#### version
64+
65+
As mentioned above, this rule reads the [`engines`] field of `package.json`.
66+
But, you can overwrite the version by `version` option.
67+
68+
The `version` option accepts [the valid version range of `node-semver`](https://github.com/npm/node-semver#range-grammar).
69+
70+
## 🔎 Implementation
71+
72+
- [Rule source](../../lib/rules/prefer-node-protocol.js)
73+
- [Test source](../../tests/lib/rules/prefer-node-protocol.js)

lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const rules = {
3939
"prefer-global/text-encoder": require("./rules/prefer-global/text-encoder"),
4040
"prefer-global/url-search-params": require("./rules/prefer-global/url-search-params"),
4141
"prefer-global/url": require("./rules/prefer-global/url"),
42+
"prefer-node-protocol": require("./rules/prefer-node-protocol"),
4243
"prefer-promises/dns": require("./rules/prefer-promises/dns"),
4344
"prefer-promises/fs": require("./rules/prefer-promises/fs"),
4445
"process-exit-as-throw": require("./rules/process-exit-as-throw"),

lib/rules/prefer-node-protocol.js

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @author Yusuke Iinuma
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
"use strict"
6+
7+
const isBuiltinModule = require("is-builtin-module")
8+
const getConfiguredNodeVersion = require("../util/get-configured-node-version")
9+
const getSemverRange = require("../util/get-semver-range")
10+
const visitImport = require("../util/visit-import")
11+
const visitRequire = require("../util/visit-require")
12+
const mergeVisitorsInPlace = require("../util/merge-visitors-in-place")
13+
14+
const messageId = "preferNodeProtocol"
15+
16+
module.exports = {
17+
meta: {
18+
docs: {
19+
description:
20+
"enforce using the `node:` protocol when importing Node.js builtin modules.",
21+
recommended: false,
22+
url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/prefer-node-protocol.md",
23+
},
24+
fixable: "code",
25+
messages: {
26+
[messageId]: "Prefer `node:{{moduleName}}` over `{{moduleName}}`.",
27+
},
28+
schema: [
29+
{
30+
type: "object",
31+
properties: {
32+
version: getConfiguredNodeVersion.schema,
33+
},
34+
additionalProperties: false,
35+
},
36+
],
37+
type: "suggestion",
38+
},
39+
create(context) {
40+
function isCallExpression(node, { name, argumentsLength }) {
41+
if (node?.type !== "CallExpression") {
42+
return false
43+
}
44+
45+
if (node.optional) {
46+
return false
47+
}
48+
49+
if (node.arguments.length !== argumentsLength) {
50+
return false
51+
}
52+
53+
if (
54+
node.callee.type !== "Identifier" ||
55+
node.callee.name !== name
56+
) {
57+
return false
58+
}
59+
60+
return true
61+
}
62+
63+
function isStringLiteral(node) {
64+
return node?.type === "Literal" && typeof node.type === "string"
65+
}
66+
67+
function isStaticRequire(node) {
68+
return (
69+
isCallExpression(node, {
70+
name: "require",
71+
argumentsLength: 1,
72+
}) && isStringLiteral(node.arguments[0])
73+
)
74+
}
75+
76+
function isEnablingThisRule(context, moduleStyle) {
77+
const version = getConfiguredNodeVersion(context)
78+
79+
const supportedVersionForEsm = "^12.20.0 || >= 14.13.1"
80+
// Only check Node.js version because this rule is meaningless if configured Node.js version doesn't match semver range.
81+
if (!version.intersects(getSemverRange(supportedVersionForEsm))) {
82+
return false
83+
}
84+
85+
const supportedVersionForCjs = "^14.18.0 || >= 16.0.0"
86+
// Only check when using `require`
87+
if (
88+
moduleStyle === "require" &&
89+
!version.intersects(getSemverRange(supportedVersionForCjs))
90+
) {
91+
return false
92+
}
93+
94+
return true
95+
}
96+
97+
const targets = []
98+
return [
99+
visitImport(context, { includeCore: true }, importTargets => {
100+
targets.push(...importTargets)
101+
}),
102+
visitRequire(context, { includeCore: true }, requireTargets => {
103+
targets.push(
104+
...requireTargets.filter(target =>
105+
isStaticRequire(target.node.parent)
106+
)
107+
)
108+
}),
109+
{
110+
"Program:exit"() {
111+
for (const { node, moduleStyle } of targets) {
112+
if (!isEnablingThisRule(context, moduleStyle)) {
113+
return
114+
}
115+
116+
if (node.type === "TemplateLiteral") {
117+
continue
118+
}
119+
120+
const { value } = node
121+
if (
122+
typeof value !== "string" ||
123+
value.startsWith("node:") ||
124+
!isBuiltinModule(value) ||
125+
!isBuiltinModule(`node:${value}`)
126+
) {
127+
return
128+
}
129+
130+
context.report({
131+
node,
132+
messageId,
133+
fix(fixer) {
134+
const firstCharacterIndex = node.range[0] + 1
135+
return fixer.replaceTextRange(
136+
[firstCharacterIndex, firstCharacterIndex],
137+
"node:"
138+
)
139+
},
140+
})
141+
}
142+
},
143+
},
144+
].reduce(
145+
(mergedVisitor, thisVisitor) =>
146+
mergeVisitorsInPlace(mergedVisitor, thisVisitor),
147+
{}
148+
)
149+
},
150+
}

lib/util/strip-import-path-params.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
"use strict"
66

77
module.exports = function stripImportPathParams(path) {
8-
const i = path.indexOf("!")
8+
const i = path.toString().indexOf("!")
99
return i === -1 ? path : path.slice(0, i)
1010
}

0 commit comments

Comments
 (0)