diff --git a/docs/rules/hashbang.md b/docs/rules/hashbang.md index 7e35aa7b..62d57a07 100644 --- a/docs/rules/hashbang.md +++ b/docs/rules/hashbang.md @@ -65,6 +65,9 @@ console.log("hello"); "convertPath": null, "ignoreUnpublished": false, "additionalExecutables": [], + "executableMap": { + ".js": "node" + } }] } ``` @@ -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) diff --git a/docs/rules/shebang.md b/docs/rules/shebang.md index 67739614..6623a354 100644 --- a/docs/rules/shebang.md +++ b/docs/rules/shebang.md @@ -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 diff --git a/lib/rules/hashbang.js b/lib/rules/hashbang.js index b7984ec2..5199e5b5 100644 --- a/lib/rules/hashbang.js +++ b/lib/rules/hashbang.js @@ -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 }} */ + const { executableMap = {} } = context.options?.[0] ?? {} + + return executableMap[extension] ?? "node" +} /** * Gets the shebang line (includes a line ending) from a given code. @@ -56,6 +96,16 @@ module.exports = { type: "array", items: { type: "string" }, }, + executableMap: { + type: "object", + patternProperties: { + "^\\.\\w+$": { + type: "string", + pattern: "^[\\w-]+$", + }, + }, + additionalProperties: false, + }, }, additionalProperties: false, }, @@ -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.", }, }, @@ -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 { @@ -130,7 +181,7 @@ module.exports = { if ( needsShebang - ? NODE_SHEBANG_PATTERN.test(info.shebang) + ? isNodeShebang(info.shebang, executableName) : !info.shebang ) { // Good the shebang target. @@ -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 + ) ) }, }) diff --git a/tests/fixtures/shebang/object-bin/package.json b/tests/fixtures/shebang/object-bin/package.json index 63af3ce9..b5e602b7 100644 --- a/tests/fixtures/shebang/object-bin/package.json +++ b/tests/fixtures/shebang/object-bin/package.json @@ -4,6 +4,7 @@ "bin": { "a": "./bin/a.js", "b": "./bin/b.js", - "c": "./bin" + "c": "./bin", + "t": "./bin/t.ts" } } diff --git a/tests/lib/rules/hashbang.js b/tests/lib/rules/hashbang.js index 8de43036..ccde4975 100644 --- a/tests/lib/rules/hashbang.js +++ b/tests/lib/rules/hashbang.js @@ -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();", }, @@ -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: [ { @@ -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".'], + }, ], })