Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: rename rule shebang => hashbang, deprecate rule shebang #198

Merged
merged 2 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
| [file-extension-in-import](docs/rules/file-extension-in-import.md) | enforce the style of file extensions in `import` declarations | | 🔧 | |
| [global-require](docs/rules/global-require.md) | require `require()` calls to be placed at top-level module scope | | | |
| [handle-callback-err](docs/rules/handle-callback-err.md) | require error handling in callbacks | | | |
| [hashbang](docs/rules/hashbang.md) | require correct usage of hashbang | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | 🔧 | |
| [no-callback-literal](docs/rules/no-callback-literal.md) | enforce Node.js-style error-first callback pattern is followed | | | |
| [no-deprecated-api](docs/rules/no-deprecated-api.md) | disallow deprecated APIs | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |
| [no-exports-assign](docs/rules/no-exports-assign.md) | disallow the assignment to `exports` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |
Expand Down Expand Up @@ -147,7 +148,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
| [prefer-promises/dns](docs/rules/prefer-promises/dns.md) | enforce `require("dns").promises` | | | |
| [prefer-promises/fs](docs/rules/prefer-promises/fs.md) | enforce `require("fs").promises` | | | |
| [process-exit-as-throw](docs/rules/process-exit-as-throw.md) | require that `process.exit()` expressions use the same code path as `throw` | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | | |
| [shebang](docs/rules/shebang.md) | require correct usage of shebang | ☑️ 🟢 ✅ ☑️ 🟢 ✅ | 🔧 | |
| [shebang](docs/rules/shebang.md) | require correct usage of hashbang | | 🔧 | |

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

Expand Down
88 changes: 88 additions & 0 deletions docs/rules/hashbang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Require correct usage of hashbang (`n/hashbang`)

💼 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`.

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

<!-- end auto-generated rule header -->

When we make a CLI tool with Node.js, we add `bin` field to `package.json`, then we add a hashbang the entry file.
This rule suggests correct usage of hashbang.

## 📖 Rule Details

This rule looks up `package.json` file from each linting target file.
Starting from the directory of the target file, it goes up ancestor directories until found.

If `package.json` was not found, this rule does nothing.

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.
Otherwise it checks whether or not there is not a hashbang.

The following patterns are considered problems for files in `bin` field of `package.json`:

```js
console.log("hello"); /*error This file needs hashbang "#!/usr/bin/env node".*/
```

```js
#!/usr/bin/env node /*error This file must not have Unicode BOM.*/
console.log("hello");
// If this file has Unicode BOM.
```

```js
#!/usr/bin/env node /*error This file must have Unix linebreaks (LF).*/
console.log("hello");
// If this file has Windows' linebreaks (CRLF).
```

The following patterns are considered problems for other files:

```js
#!/usr/bin/env node /*error This file needs no hashbang.*/
console.log("hello");
```

The following patterns are not considered problems for files in `bin` field of `package.json`:

```js
#!/usr/bin/env node
console.log("hello");
```

The following patterns are not considered problems for other files:

```js
console.log("hello");
```

### Options

```json
{
"n/hashbang": ["error", {
"convertPath": null,
"ignoreUnpublished": false,
"additionalExecutables": [],
}]
}
```

#### convertPath

This can be configured in the rule options or as a shared setting [`settings.convertPath`](../shared-settings.md#convertpath).
Please see the shared settings documentation for more information.

#### ignoreUnpublished

Allow for files that are not published to npm to be ignored by this rule.

#### additionalExecutables

Mark files as executable that are not referenced by the package.json#bin property

## 🔎 Implementation

- [Rule source](../../lib/rules/hashbang.js)
- [Test source](../../tests/lib/rules/hashbang.js)
4 changes: 2 additions & 2 deletions docs/rules/shebang.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Require correct usage of shebang (`n/shebang`)
# Require correct usage of hashbang (`n/shebang`)

💼 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`.
This rule is deprecated. It was replaced by [`n/hashbang`](hashbang.md).
scagood marked this conversation as resolved.
Show resolved Hide resolved

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

Expand Down
2 changes: 1 addition & 1 deletion lib/configs/_commons.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ module.exports = {
"n/no-unsupported-features/es-syntax": "error",
"n/no-unsupported-features/node-builtins": "error",
"n/process-exit-as-throw": "error",
"n/shebang": "error",
"n/hashbang": "error",
},
}
3 changes: 2 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ const rules = {
"prefer-promises/dns": require("./rules/prefer-promises/dns"),
"prefer-promises/fs": require("./rules/prefer-promises/fs"),
"process-exit-as-throw": require("./rules/process-exit-as-throw"),
shebang: require("./rules/shebang"),
hashbang: require("./rules/hashbang"),

// Deprecated rules.
"no-hide-core-modules": require("./rules/no-hide-core-modules"),
shebang: require("./rules/shebang"),
}

const mod = {
Expand Down
212 changes: 212 additions & 0 deletions lib/rules/hashbang.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* @author Toru Nagashima
* See LICENSE file in root directory for full license.
*/
"use strict"

const path = require("path")
const matcher = require("ignore")

const getConvertPath = require("../util/get-convert-path")
const getPackageJson = require("../util/get-package-json")
const getNpmignore = require("../util/get-npmignore")

const NODE_SHEBANG = "#!/usr/bin/env node\n"
const SHEBANG_PATTERN = /^(#!.+?)?(\r)?\n/u
const NODE_SHEBANG_PATTERN =
/^#!\/usr\/bin\/env(?: -\S+)*(?: [^\s=-]+=\S+)* node(?: [^\r\n]+?)?\n/u

function simulateNodeResolutionAlgorithm(filePath, binField) {
const possibilities = [filePath]
let newFilePath = filePath.replace(/\.js$/u, "")
possibilities.push(newFilePath)
newFilePath = newFilePath.replace(/[/\\]index$/u, "")
possibilities.push(newFilePath)
return possibilities.includes(binField)
}

/**
* Checks whether or not a given path is a `bin` file.
*
* @param {string} filePath - A file path to check.
* @param {string|object|undefined} binField - A value of the `bin` field of `package.json`.
* @param {string} basedir - A directory path that `package.json` exists.
* @returns {boolean} `true` if the file is a `bin` file.
*/
function isBinFile(filePath, binField, basedir) {
if (!binField) {
return false
}
if (typeof binField === "string") {
return simulateNodeResolutionAlgorithm(
filePath,
path.resolve(basedir, binField)
)
}
return Object.keys(binField).some(key =>
simulateNodeResolutionAlgorithm(
filePath,
path.resolve(basedir, binField[key])
)
)
}

/**
* Gets the shebang line (includes a line ending) from a given code.
*
* @param {SourceCode} sourceCode - A source code object to check.
* @returns {{length: number, bom: boolean, shebang: string, cr: boolean}}
* shebang's information.
* `retv.shebang` is an empty string if shebang doesn't exist.
*/
function getShebangInfo(sourceCode) {
const m = SHEBANG_PATTERN.exec(sourceCode.text)

return {
bom: sourceCode.hasBOM,
cr: Boolean(m && m[2]),
length: (m && m[0].length) || 0,
shebang: (m && m[1] && `${m[1]}\n`) || "",
}
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
docs: {
description: "require correct usage of hashbang",
recommended: true,
url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/hashbang.md",
},
type: "problem",
fixable: "code",
schema: [
{
type: "object",
properties: {
convertPath: getConvertPath.schema,
ignoreUnpublished: { type: "boolean" },
additionalExecutables: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
],
messages: {
unexpectedBOM: "This file must not have Unicode BOM.",
expectedLF: "This file must have Unix linebreaks (LF).",
expectedHashbangNode:
'This file needs shebang "#!/usr/bin/env node".',
expectedHashbang: "This file needs no shebang.",
},
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode() // TODO: just use context.sourceCode when dropping eslint < v9
const filePath = context.filename ?? context.getFilename()
if (filePath === "<input>") {
return {}
}

const p = getPackageJson(filePath)
if (!p) {
return {}
}

const packageDirectory = path.dirname(p.filePath)

const originalAbsolutePath = path.resolve(filePath)
const originalRelativePath = path
.relative(packageDirectory, originalAbsolutePath)
.replace(/\\/gu, "/")

const convertedRelativePath =
getConvertPath(context)(originalRelativePath)
const convertedAbsolutePath = path.resolve(
packageDirectory,
convertedRelativePath
)

const { additionalExecutables = [] } = context.options?.[0] ?? {}

const executable = matcher()
executable.add(additionalExecutables)
const isExecutable = executable.test(convertedRelativePath)

if (
(additionalExecutables.length === 0 ||
isExecutable.ignored === false) &&
context.options?.[0]?.ignoreUnpublished === true
) {
const npmignore = getNpmignore(convertedAbsolutePath)

if (npmignore.match(convertedRelativePath)) {
return {}
}
}

const needsShebang =
isExecutable.ignored === true ||
isBinFile(convertedAbsolutePath, p.bin, packageDirectory)
const info = getShebangInfo(sourceCode)

return {
Program() {
const loc = {
start: { line: 1, column: 0 },
end: { line: 1, column: sourceCode.lines.at(0).length },
}

if (
needsShebang
? NODE_SHEBANG_PATTERN.test(info.shebang)
: !info.shebang
) {
// Good the shebang target.
// Checks BOM and \r.
if (needsShebang && info.bom) {
context.report({
loc,
messageId: "unexpectedBOM",
fix(fixer) {
return fixer.removeRange([-1, 0])
},
})
}
if (needsShebang && info.cr) {
context.report({
loc,
messageId: "expectedLF",
fix(fixer) {
const index = sourceCode.text.indexOf("\r")
return fixer.removeRange([index, index + 1])
},
})
}
} else if (needsShebang) {
// Shebang is lacking.
context.report({
loc,
messageId: "expectedHashbangNode",
fix(fixer) {
return fixer.replaceTextRange(
[-1, info.length],
NODE_SHEBANG
)
},
})
} else {
// Shebang is extra.
context.report({
loc,
messageId: "expectedHashbang",
fix(fixer) {
return fixer.removeRange([0, info.length])
},
})
}
},
}
},
}
Loading