Skip to content

Commit 427b0d6

Browse files
committed
feat!: rename rule shebang => hashbang, deprecate rule shebang
fixes ##196
1 parent b383b49 commit 427b0d6

File tree

9 files changed

+787
-669
lines changed

9 files changed

+787
-669
lines changed

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
114114
| [file-extension-in-import](docs/rules/file-extension-in-import.md) | enforce the style of file extensions in `import` declarations | | 🔧 | |
115115
| [global-require](docs/rules/global-require.md) | require `require()` calls to be placed at top-level module scope | | | |
116116
| [handle-callback-err](docs/rules/handle-callback-err.md) | require error handling in callbacks | | | |
117+
| [hashbang](docs/rules/hashbang.md) | require correct usage of hashbang | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | 🔧 | |
117118
| [no-callback-literal](docs/rules/no-callback-literal.md) | enforce Node.js-style error-first callback pattern is followed | | | |
118119
| [no-deprecated-api](docs/rules/no-deprecated-api.md) | disallow deprecated APIs | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |
119120
| [no-exports-assign](docs/rules/no-exports-assign.md) | disallow the assignment to `exports` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |
@@ -147,7 +148,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
147148
| [prefer-promises/dns](docs/rules/prefer-promises/dns.md) | enforce `require("dns").promises` | | | |
148149
| [prefer-promises/fs](docs/rules/prefer-promises/fs.md) | enforce `require("fs").promises` | | | |
149150
| [process-exit-as-throw](docs/rules/process-exit-as-throw.md) | require that `process.exit()` expressions use the same code path as `throw` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |
150-
| [shebang](docs/rules/shebang.md) | require correct usage of shebang | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | 🔧 | |
151+
| [shebang](docs/rules/shebang.md) | require correct usage of hashbang | | 🔧 | |
151152

152153
<!-- end auto-generated rules list -->
153154

docs/rules/hashbang.md

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Require correct usage of hashbang (`n/hashbang`)
2+
3+
💼 This rule is enabled in the following [configs](https://github.com/eslint-community/eslint-plugin-n#-configs): ☑️ `flat/recommended`, 🟢 `flat/recommended-module`, ✅ `flat/recommended-script`, ☑️ `recommended`, 🟢 `recommended-module`, ✅ `recommended-script`.
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
9+
When we make a CLI tool with Node.js, we add `bin` field to `package.json`, then we add a hashbang the entry file.
10+
This rule suggests correct usage of hashbang.
11+
12+
## 📖 Rule Details
13+
14+
This rule looks up `package.json` file from each linting target file.
15+
Starting from the directory of the target file, it goes up ancestor directories until found.
16+
17+
If `package.json` was not found, this rule does nothing.
18+
19+
This rule checks `bin` field of `package.json`, then if a target file matches one of `bin` files, it checks whether or not there is a correct hashbang.
20+
Otherwise it checks whether or not there is not a hashbang.
21+
22+
The following patterns are considered problems for files in `bin` field of `package.json`:
23+
24+
```js
25+
console.log("hello"); /*error This file needs hashbang "#!/usr/bin/env node".*/
26+
```
27+
28+
```js
29+
#!/usr/bin/env node /*error This file must not have Unicode BOM.*/
30+
console.log("hello");
31+
// If this file has Unicode BOM.
32+
```
33+
34+
```js
35+
#!/usr/bin/env node /*error This file must have Unix linebreaks (LF).*/
36+
console.log("hello");
37+
// If this file has Windows' linebreaks (CRLF).
38+
```
39+
40+
The following patterns are considered problems for other files:
41+
42+
```js
43+
#!/usr/bin/env node /*error This file needs no hashbang.*/
44+
console.log("hello");
45+
```
46+
47+
The following patterns are not considered problems for files in `bin` field of `package.json`:
48+
49+
```js
50+
#!/usr/bin/env node
51+
console.log("hello");
52+
```
53+
54+
The following patterns are not considered problems for other files:
55+
56+
```js
57+
console.log("hello");
58+
```
59+
60+
### Options
61+
62+
```json
63+
{
64+
"n/hashbang": ["error", {
65+
"convertPath": null,
66+
"ignoreUnpublished": false,
67+
"additionalExecutables": [],
68+
}]
69+
}
70+
```
71+
72+
#### convertPath
73+
74+
This can be configured in the rule options or as a shared setting [`settings.convertPath`](../shared-settings.md#convertpath).
75+
Please see the shared settings documentation for more information.
76+
77+
#### ignoreUnpublished
78+
79+
Allow for files that are not published to npm to be ignored by this rule.
80+
81+
#### additionalExecutables
82+
83+
Mark files as executable that are not referenced by the package.json#bin property
84+
85+
## 🔎 Implementation
86+
87+
- [Rule source](../../lib/rules/hashbang.js)
88+
- [Test source](../../tests/lib/rules/hashbang.js)

docs/rules/shebang.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Require correct usage of shebang (`n/shebang`)
1+
# Require correct usage of hashbang (`n/shebang`)
22

3-
💼 This rule is enabled in the following [configs](https://github.com/eslint-community/eslint-plugin-n#-configs): ☑️ `flat/recommended`, 🟢 `flat/recommended-module`, ✅ `flat/recommended-script`, ☑️ `recommended`, 🟢 `recommended-module`, ✅ `recommended-script`.
3+
This rule is deprecated. It was replaced by [`n/hashbang`](hashbang.md).
44

55
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
66

lib/configs/_commons.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ module.exports = {
1616
"n/no-unsupported-features/es-syntax": "error",
1717
"n/no-unsupported-features/node-builtins": "error",
1818
"n/process-exit-as-throw": "error",
19-
"n/shebang": "error",
19+
"n/hashbang": "error",
2020
},
2121
}

lib/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ const rules = {
4343
"prefer-promises/dns": require("./rules/prefer-promises/dns"),
4444
"prefer-promises/fs": require("./rules/prefer-promises/fs"),
4545
"process-exit-as-throw": require("./rules/process-exit-as-throw"),
46-
shebang: require("./rules/shebang"),
46+
hashbang: require("./rules/hashbang"),
4747

4848
// Deprecated rules.
4949
"no-hide-core-modules": require("./rules/no-hide-core-modules"),
50+
shebang: require("./rules/shebang"),
5051
}
5152

5253
const mod = {

lib/rules/hashbang.js

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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 matcher = require("ignore")
9+
10+
const getConvertPath = require("../util/get-convert-path")
11+
const getPackageJson = require("../util/get-package-json")
12+
const getNpmignore = require("../util/get-npmignore")
13+
14+
const NODE_SHEBANG = "#!/usr/bin/env node\n"
15+
const SHEBANG_PATTERN = /^(#!.+?)?(\r)?\n/u
16+
const NODE_SHEBANG_PATTERN = /^#!\/usr\/bin\/env(?: -\S+)*(?: [^\s=-]+=\S+)* node(?: [^\r\n]+?)?\n/u
17+
18+
function simulateNodeResolutionAlgorithm(filePath, binField) {
19+
const possibilities = [filePath]
20+
let newFilePath = filePath.replace(/\.js$/u, "")
21+
possibilities.push(newFilePath)
22+
newFilePath = newFilePath.replace(/[/\\]index$/u, "")
23+
possibilities.push(newFilePath)
24+
return possibilities.includes(binField)
25+
}
26+
27+
/**
28+
* Checks whether or not a given path is a `bin` file.
29+
*
30+
* @param {string} filePath - A file path to check.
31+
* @param {string|object|undefined} binField - A value of the `bin` field of `package.json`.
32+
* @param {string} basedir - A directory path that `package.json` exists.
33+
* @returns {boolean} `true` if the file is a `bin` file.
34+
*/
35+
function isBinFile(filePath, binField, basedir) {
36+
if (!binField) {
37+
return false
38+
}
39+
if (typeof binField === "string") {
40+
return simulateNodeResolutionAlgorithm(
41+
filePath,
42+
path.resolve(basedir, binField)
43+
)
44+
}
45+
return Object.keys(binField).some(key =>
46+
simulateNodeResolutionAlgorithm(
47+
filePath,
48+
path.resolve(basedir, binField[key])
49+
)
50+
)
51+
}
52+
53+
/**
54+
* Gets the shebang line (includes a line ending) from a given code.
55+
*
56+
* @param {SourceCode} sourceCode - A source code object to check.
57+
* @returns {{length: number, bom: boolean, shebang: string, cr: boolean}}
58+
* shebang's information.
59+
* `retv.shebang` is an empty string if shebang doesn't exist.
60+
*/
61+
function getShebangInfo(sourceCode) {
62+
const m = SHEBANG_PATTERN.exec(sourceCode.text)
63+
64+
return {
65+
bom: sourceCode.hasBOM,
66+
cr: Boolean(m && m[2]),
67+
length: (m && m[0].length) || 0,
68+
shebang: (m && m[1] && `${m[1]}\n`) || "",
69+
}
70+
}
71+
72+
/** @type {import('eslint').Rule.RuleModule} */
73+
module.exports = {
74+
meta: {
75+
docs: {
76+
description: "require correct usage of hashbang",
77+
recommended: true,
78+
url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/hashbang.md",
79+
},
80+
type: "problem",
81+
fixable: "code",
82+
schema: [
83+
{
84+
type: "object",
85+
properties: {
86+
convertPath: getConvertPath.schema,
87+
ignoreUnpublished: { type: "boolean" },
88+
additionalExecutables: {
89+
type: "array",
90+
items: { type: "string" },
91+
},
92+
},
93+
additionalProperties: false,
94+
},
95+
],
96+
messages: {
97+
unexpectedBOM: "This file must not have Unicode BOM.",
98+
expectedLF: "This file must have Unix linebreaks (LF).",
99+
expectedHashbangNode:
100+
'This file needs shebang "#!/usr/bin/env node".',
101+
expectedHashbang: "This file needs no shebang.",
102+
},
103+
},
104+
create(context) {
105+
const sourceCode = context.sourceCode ?? context.getSourceCode() // TODO: just use context.sourceCode when dropping eslint < v9
106+
const filePath = context.filename ?? context.getFilename()
107+
if (filePath === "<input>") {
108+
return {}
109+
}
110+
111+
const p = getPackageJson(filePath)
112+
if (!p) {
113+
return {}
114+
}
115+
116+
const packageDirectory = path.dirname(p.filePath)
117+
118+
const originalAbsolutePath = path.resolve(filePath)
119+
const originalRelativePath = path
120+
.relative(packageDirectory, originalAbsolutePath)
121+
.replace(/\\/gu, "/")
122+
123+
const convertedRelativePath =
124+
getConvertPath(context)(originalRelativePath)
125+
const convertedAbsolutePath = path.resolve(
126+
packageDirectory,
127+
convertedRelativePath
128+
)
129+
130+
const { additionalExecutables = [] } = context.options?.[0] ?? {}
131+
132+
const executable = matcher()
133+
executable.add(additionalExecutables)
134+
const isExecutable = executable.test(convertedRelativePath)
135+
136+
if (
137+
(additionalExecutables.length === 0 ||
138+
isExecutable.ignored === false) &&
139+
context.options?.[0]?.ignoreUnpublished === true
140+
) {
141+
const npmignore = getNpmignore(convertedAbsolutePath)
142+
143+
if (npmignore.match(convertedRelativePath)) {
144+
return {}
145+
}
146+
}
147+
148+
const needsShebang =
149+
isExecutable.ignored === true ||
150+
isBinFile(convertedAbsolutePath, p.bin, packageDirectory)
151+
const info = getShebangInfo(sourceCode)
152+
153+
return {
154+
Program() {
155+
const loc = {
156+
start: { line: 1, column: 0 },
157+
end: { line: 1, column: sourceCode.lines.at(0).length },
158+
}
159+
160+
if (
161+
needsShebang
162+
? NODE_SHEBANG_PATTERN.test(info.shebang)
163+
: !info.shebang
164+
) {
165+
// Good the shebang target.
166+
// Checks BOM and \r.
167+
if (needsShebang && info.bom) {
168+
context.report({
169+
loc,
170+
messageId: "unexpectedBOM",
171+
fix(fixer) {
172+
return fixer.removeRange([-1, 0])
173+
},
174+
})
175+
}
176+
if (needsShebang && info.cr) {
177+
context.report({
178+
loc,
179+
messageId: "expectedLF",
180+
fix(fixer) {
181+
const index = sourceCode.text.indexOf("\r")
182+
return fixer.removeRange([index, index + 1])
183+
},
184+
})
185+
}
186+
} else if (needsShebang) {
187+
// Shebang is lacking.
188+
context.report({
189+
loc,
190+
messageId: "expectedHashbangNode",
191+
fix(fixer) {
192+
return fixer.replaceTextRange(
193+
[-1, info.length],
194+
NODE_SHEBANG
195+
)
196+
},
197+
})
198+
} else {
199+
// Shebang is extra.
200+
context.report({
201+
loc,
202+
messageId: "expectedHashbang",
203+
fix(fixer) {
204+
return fixer.removeRange([0, info.length])
205+
},
206+
})
207+
}
208+
},
209+
}
210+
},
211+
}

0 commit comments

Comments
 (0)