Skip to content

Commit cefdb1c

Browse files
authored
feat!: rename rule shebang => hashbang, deprecate rule shebang (#198)
fixes #196
1 parent b383b49 commit cefdb1c

File tree

9 files changed

+788
-669
lines changed

9 files changed

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

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

0 commit comments

Comments
 (0)