Skip to content

Commit 5cdeba2

Browse files
committed
feat!: add rule hashbang, deprecate rule shebang
fixes ##196
1 parent 4778ae8 commit 5cdeba2

File tree

6 files changed

+670
-639
lines changed

6 files changed

+670
-639
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
147147
| [prefer-promises/dns](docs/rules/prefer-promises/dns.md) | enforce `require("dns").promises` | | | |
148148
| [prefer-promises/fs](docs/rules/prefer-promises/fs.md) | enforce `require("fs").promises` | | | |
149149
| [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 | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | 🔧 | |
150+
| [shebang](docs/rules/shebang.md) | require correct usage of shebang | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | 🔧 | |
151151

152152
<!-- end auto-generated rules list -->
153153

docs/rules/shebang.md

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

3+
❌ This rule is deprecated. It was replaced by `n/hashbang`.
4+
35
💼 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`.
46

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

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 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 shebang",
77+
recommended: true,
78+
url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/shebang.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)