Skip to content

Commit

Permalink
feat(hashbang): Add support to map extensions to executables (#278)
Browse files Browse the repository at this point in the history
* test: Rename shebang tests to be more verbose

* chore: Prepare for #220 by making shebang checks more verbose

* feat(hashbang): Add support to map extensions to executables

* chore: remove "\b" in char group

* docs(hashbang): Add docs for "executableMap"
  • Loading branch information
scagood authored May 14, 2024
1 parent 704f0b9 commit 3fd7639
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 82 deletions.
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)) {
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".'],
},
],
})

0 comments on commit 3fd7639

Please sign in to comment.