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(hashbang): Add support to map extensions to executables #278

Merged
merged 5 commits into from
May 14, 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
15 changes: 15 additions & 0 deletions docs/rules/hashbang.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ console.log("hello");
"convertPath": null,
"ignoreUnpublished": false,
"additionalExecutables": [],
"executableMap": {
".js": "node"
}
}]
}
```
Expand All @@ -82,6 +85,18 @@ Allow for files that are not published to npm to be ignored by this rule.

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

#### executableMap

Allow for different executables to be used based on file extension.
This is in the form `"{extension}": "{binaryName}"`.

```js
{
".js": "node",
".ts": "ts-node"
}
```

## 🔎 Implementation

- [Rule source](../../lib/rules/hashbang.js)
Expand Down
71 changes: 1 addition & 70 deletions docs/rules/shebang.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,76 +11,7 @@ This rule suggests correct usage of shebang.

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

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

```js
console.log("hello"); /*error This file needs shebang "#!/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 shebang.*/
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/shebang": ["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
The details for this rule can be found in [docs/rules/hashbang.md](https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/hashbang.md#-rule-details)

## 🔎 Implementation

Expand Down
67 changes: 61 additions & 6 deletions lib/rules/hashbang.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,50 @@ const { getPackageJson } = require("../util/get-package-json")
const getNpmignore = require("../util/get-npmignore")
const { isBinFile } = require("../util/is-bin-file")

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

// -i -S
// -u name
// --ignore-environment
// --block-signal=SIGINT
const ENV_FLAGS = /^\s*-(-.*?\b|[ivS]+|[Pu](\s+|=)\S+)(?=\s|$)/

// NAME="some variable"
// FOO=bar
const ENV_VARS = /^\s*\w+=(?:"(?:[^"\\]|\\.)*"|\w+)/

/**
* @param {string} shebang
* @param {string} executableName
* @returns {boolean}
*/
function isNodeShebang(shebang, executableName) {
if (shebang == null || shebang.length === 0) {
return false
}

shebang = shebang.slice(shebang.indexOf(ENV_SHEBANG) + ENV_SHEBANG.length)
while (ENV_FLAGS.test(shebang) || ENV_VARS.test(shebang)) {
aladdin-add marked this conversation as resolved.
Show resolved Hide resolved
shebang = shebang.replace(ENV_FLAGS, "").replace(ENV_VARS, "")
}

const [command] = shebang.trim().split(" ")
return command === executableName
}

/**
* @param {import('eslint').Rule.RuleContext} context The rule context.
* @returns {string}
*/
function getExpectedExecutableName(context) {
const extension = path.extname(context.filename)
/** @type {{ executableMap: Record<string, string> }} */
const { executableMap = {} } = context.options?.[0] ?? {}

return executableMap[extension] ?? "node"
}

/**
* Gets the shebang line (includes a line ending) from a given code.
Expand Down Expand Up @@ -56,6 +96,16 @@ module.exports = {
type: "array",
items: { type: "string" },
},
executableMap: {
type: "object",
patternProperties: {
"^\\.\\w+$": {
type: "string",
pattern: "^[\\w-]+$",
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
Expand All @@ -64,7 +114,7 @@ module.exports = {
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".',
'This file needs shebang "#!/usr/bin/env {{executableName}}".',
expectedHashbang: "This file needs no shebang.",
},
},
Expand Down Expand Up @@ -116,6 +166,7 @@ module.exports = {
const needsShebang =
isExecutable.ignored === true ||
isBinFile(convertedAbsolutePath, packageJson?.bin, packageDirectory)
const executableName = getExpectedExecutableName(context)
const info = getShebangInfo(sourceCode)

return {
Expand All @@ -130,7 +181,7 @@ module.exports = {

if (
needsShebang
? NODE_SHEBANG_PATTERN.test(info.shebang)
? isNodeShebang(info.shebang, executableName)
: !info.shebang
) {
// Good the shebang target.
Expand Down Expand Up @@ -159,10 +210,14 @@ module.exports = {
context.report({
loc,
messageId: "expectedHashbangNode",
data: { executableName },
fix(fixer) {
return fixer.replaceTextRange(
[-1, info.length],
NODE_SHEBANG
NODE_SHEBANG.replaceAll(
"{{executableName}}",
executableName
)
)
},
})
Expand Down
3 changes: 2 additions & 1 deletion tests/fixtures/shebang/object-bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"bin": {
"a": "./bin/a.js",
"b": "./bin/b.js",
"c": "./bin"
"c": "./bin",
"t": "./bin/t.ts"
}
}
42 changes: 37 additions & 5 deletions tests/lib/rules/hashbang.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,27 @@ ruleTester.run("shebang", rule, {
code: "#!/usr/bin/env node\nhello();",
},
{
name: "string-bin/bin/test.js",
name: "string-bin/bin/test-env-flag.js",
filename: fixture("string-bin/bin/test.js"),
code: "#!/usr/bin/env -S node\nhello();",
},
{
name: "string-bin/bin/test.js",
name: "string-bin/bin/test-env-flag-node-flag.js",
filename: fixture("string-bin/bin/test.js"),
code: "#!/usr/bin/env -S node --loader tsm\nhello();",
},
{
name: "string-bin/bin/test.js",
name: "string-bin/bin/test-env-ignore-environment.js",
filename: fixture("string-bin/bin/test.js"),
code: "#!/usr/bin/env --ignore-environment node\nhello();",
},
{
name: "string-bin/bin/test.js",
name: "string-bin/bin/test-env-flags-node-flag.js",
filename: fixture("string-bin/bin/test.js"),
code: "#!/usr/bin/env -i -S node --loader tsm\nhello();",
},
{
name: "string-bin/bin/test.js",
name: "string-bin/bin/test-block-signal.js",
filename: fixture("string-bin/bin/test.js"),
code: "#!/usr/bin/env --block-signal=SIGINT -S FOO=bar node --loader tsm\nhello();",
},
Expand Down Expand Up @@ -204,6 +204,20 @@ ruleTester.run("shebang", rule, {
code: "#!/usr/bin/env node\nhello();",
options: [{ additionalExecutables: ["*.test.js"] }],
},

// executableMap
{
name: ".ts maps to ts-node",
filename: fixture("object-bin/bin/t.ts"),
code: "#!/usr/bin/env ts-node\nhello();",
options: [{ executableMap: { ".ts": "ts-node" } }],
},
{
name: ".ts maps to ts-node",
filename: fixture("object-bin/bin/a.js"),
code: "#!/usr/bin/env node\nhello();",
options: [{ executableMap: { ".ts": "ts-node" } }],
},
],
invalid: [
{
Expand Down Expand Up @@ -461,5 +475,23 @@ ruleTester.run("shebang", rule, {
output: "hello();",
errors: ["This file needs no shebang."],
},

// executableMap
{
name: ".ts maps to ts-node",
filename: fixture("object-bin/bin/t.ts"),
code: "hello();",
options: [{ executableMap: { ".ts": "ts-node" } }],
output: "#!/usr/bin/env ts-node\nhello();",
errors: ['This file needs shebang "#!/usr/bin/env ts-node".'],
},
{
name: ".ts maps to ts-node",
filename: fixture("object-bin/bin/t.ts"),
code: "#!/usr/bin/env node\nhello();",
options: [{ executableMap: { ".ts": "ts-node" } }],
output: "#!/usr/bin/env ts-node\nhello();",
errors: ['This file needs shebang "#!/usr/bin/env ts-node".'],
},
],
})