diff --git a/README.md b/README.md index 7fd586e..7a051a3 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Each rule mirrors a well-known ShellCheck check. | [`no-backticks`](./docs/rules/no-backticks.md) | SC2006 | Use `$(...)` instead of legacy backticks | ✅ | error | | [`no-expansions-in-single-quotes`](./docs/rules/no-expansions-in-single-quotes.md) | SC2016 | Expressions don't expand in single quotes | | warn | | [`no-unquoted-expansions`](./docs/rules/no-unquoted-expansions.md) | SC2086/46 | Quote expansions subject to word splitting and globbing | ✅ | error | +| [`no-useless-echo`](./docs/rules/no-useless-echo.md) | SC2116 | Disallow `$(echo ...)` | | error | ## Configuration comments diff --git a/docs/rules/no-useless-echo.md b/docs/rules/no-useless-echo.md new file mode 100644 index 0000000..ac34600 --- /dev/null +++ b/docs/rules/no-useless-echo.md @@ -0,0 +1,53 @@ +# no-useless-echo + +Disallow useless `echo` inside command substitutions. + +## Background + +A command substitution that only runs `echo`, such as `$(echo "$name")`, produces the same text that was passed to `echo`. It starts an extra process (or at least an extra step) and makes the code harder to read. Using the value directly is simpler. + +## Rule Details + +This rule warns about a command substitution, written as `$(...)` or with backticks, whose entire body is a single `echo` command without redirects. + +The rule doesn't inspect `echo`'s options, so `$(echo -n "$value")` is also reported. + +Substitutions that do more than echo, such as pipelines (`$(echo "$name" | tr a-z A-Z)`) or multiple commands, are not reported. + +Examples of **incorrect** code for this rule: + +```bash +# eslint shell/no-useless-echo: "error" + +name=$(echo "$first") + +ls $(echo /tmp) + +files=`echo "$dir"` +``` + +Examples of **correct** code for this rule: + +```bash +# eslint shell/no-useless-echo: "error" + +name="$first" + +upper=$(echo "$name" | tr a-z A-Z) + +both=$(echo hello; echo world) + +echo "hello" +``` + +## Options + +This rule has no options. + +## When Not to Use It + +`$(echo ...)` is occasionally used on purpose for side effects of `echo`, such as collapsing whitespace in an unquoted value or interpreting escape sequences with `echo -e`. If you rely on those behaviors, use a disable comment for those lines or disable this rule. + +## Prior Art + +- [SC2116](https://www.shellcheck.net/wiki/SC2116) diff --git a/src/index.spec.ts b/src/index.spec.ts index 17ec809..32f7e1e 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -38,6 +38,7 @@ describe("plugin", () => { "no-backticks", "no-expansions-in-single-quotes", "no-unquoted-expansions", + "no-useless-echo", ]); }); diff --git a/src/index.ts b/src/index.ts index 9922626..7bbaa47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,13 @@ import { ShellLanguage } from "./languages/shell-language.js"; import noBackticks from "./rules/no-backticks.js"; import noExpansionsInSingleQuotes from "./rules/no-expansions-in-single-quotes.js"; import noUnquotedExpansions from "./rules/no-unquoted-expansions.js"; +import noUselessEcho from "./rules/no-useless-echo.js"; const rules = { "no-backticks": noBackticks, "no-expansions-in-single-quotes": noExpansionsInSingleQuotes, "no-unquoted-expansions": noUnquotedExpansions, + "no-useless-echo": noUselessEcho, }; const plugin = { @@ -36,6 +38,7 @@ const plugin = { "shell/no-backticks": "error", "shell/no-expansions-in-single-quotes": "warn", "shell/no-unquoted-expansions": "error", + "shell/no-useless-echo": "error", }, }, }, diff --git a/src/rules/no-useless-echo.spec.ts b/src/rules/no-useless-echo.spec.ts new file mode 100644 index 0000000..5e096d8 --- /dev/null +++ b/src/rules/no-useless-echo.spec.ts @@ -0,0 +1,50 @@ +/** + * @fileoverview Tests for the no-useless-echo rule. + */ + +import { RuleTester } from "eslint"; +import { ShellLanguage } from "../languages/shell-language.js"; +import rule from "./no-useless-echo.js"; + +const ruleTester = new RuleTester({ + plugins: { + shell: { + meta: { namespace: "shell" }, + languages: { bash: new ShellLanguage() }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin shape is validated by ESLint at runtime. + } as any, + language: "shell/bash", +}); + +ruleTester.run("no-useless-echo", rule as never, { + valid: [ + "echo hello", + "x=$(pwd)", + "x=$(echo foo | tr a-z A-Z)", + "x=$(echo foo; echo bar)", + "x=$(echo foo > file)", + "x=$(! echo foo)", + ], + invalid: [ + { + code: "x=$(echo foo)", + errors: [ + { + messageId: "uselessEcho", + line: 1, + column: 3, + endColumn: 14, + }, + ], + }, + { + code: 'cmd "$(echo "$var")"', + errors: [{ messageId: "uselessEcho" }], + }, + { + code: "x=`echo foo`", + errors: [{ messageId: "uselessEcho" }], + }, + ], +}); diff --git a/src/rules/no-useless-echo.ts b/src/rules/no-useless-echo.ts new file mode 100644 index 0000000..d870194 --- /dev/null +++ b/src/rules/no-useless-echo.ts @@ -0,0 +1,48 @@ +/** + * @fileoverview Rule to disallow `$(echo ...)` command substitutions. + * Mirrors ShellCheck SC2116. + */ + +import { isCommandNamed } from "./utils.js"; +import type { ShellRuleDefinition } from "../types.js"; + +const rule: ShellRuleDefinition<{ MessageIds: "uselessEcho" }> = { + meta: { + type: "suggestion", + languages: ["shell/bash", "shell/posix", "shell/mksh"], + docs: { + description: "Disallow useless `echo` inside command substitutions", + recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], + url: "https://github.com/eslint/shell/blob/main/docs/rules/no-useless-echo.md", + }, + schema: [], + messages: { + uselessEcho: + "Useless echo? Instead of 'cmd $(echo foo)', just use 'cmd foo'. (ShellCheck SC2116)", + }, + }, + + create(context) { + return { + CommandSubstitution(node) { + const first = node.body[0]; + + if ( + node.body.length === 1 && + first !== undefined && + isCommandNamed(first, "echo") && + !first.negated && + first.redirects.length === 0 + ) { + context.report({ + node, + messageId: "uselessEcho", + }); + } + }, + }; + }, +}; + +export default rule;