Skip to content

Commit

Permalink
feat!: rename rule shebang => hashbang, deprecate rule shebang (#198)
Browse files Browse the repository at this point in the history
fixes #196
  • Loading branch information
aladdin-add authored Mar 6, 2024
1 parent b383b49 commit cefdb1c
Show file tree
Hide file tree
Showing 9 changed files with 788 additions and 669 deletions.
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).

🔧 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

0 comments on commit cefdb1c

Please sign in to comment.