diff --git a/README.md b/README.md index d111353..ca1cd65 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,10 @@ export default [ Each rule mirrors a well-known ShellCheck check. -| Rule | ShellCheck | Description | Fixable | Recommended | -| ---------------------------------------------- | ---------- | ---------------------------------------- | ------- | ----------- | -| [`no-backticks`](./docs/rules/no-backticks.md) | SC2006 | Use `$(...)` instead of legacy backticks | ✅ | error | +| Rule | ShellCheck | Description | Fixable | Recommended | +| ---------------------------------------------------------------------------------- | ---------- | ----------------------------------------- | ------- | ----------- | +| [`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 | ## Configuration comments diff --git a/docs/rules/no-expansions-in-single-quotes.md b/docs/rules/no-expansions-in-single-quotes.md new file mode 100644 index 0000000..d19b876 --- /dev/null +++ b/docs/rules/no-expansions-in-single-quotes.md @@ -0,0 +1,57 @@ +# no-expansions-in-single-quotes + +Disallow expansion-like syntax inside single quotes, where it is not expanded. + +## Background + +Inside single quotes, the shell treats every character literally. Variables, command substitutions, and backticks are not expanded, so `'$HOME'` is the five characters `$HOME`, not your home directory. Writing an expansion inside single quotes is usually a mistake where double quotes were intended. + +## Rule Details + +This rule warns when a single-quoted string contains text that looks like an expansion: `$name`, `${...}`, `$(...)`, or text wrapped in a pair of backticks. + +The rule does not warn about: + +- Positional-style references such as `$1`. These are common in `awk` and `sed` programs, where single quotes are used on purpose so the shell leaves them alone. +- A `$` followed by a digit or punctuation, such as `'costs $5'`. +- ANSI-C quoted strings (`$'...'`). + +Examples of **incorrect** code for this rule: + +```bash +# eslint shell/no-expansions-in-single-quotes: "error" + +echo 'Hello, $USER' + +echo 'Home is ${HOME}' + +echo 'Today is $(date)' + +echo 'Run `make` first' +``` + +Examples of **correct** code for this rule: + +```bash +# eslint shell/no-expansions-in-single-quotes: "error" + +echo "Hello, $USER" + +awk '{ print $1 }' data.txt + +echo 'That costs $5' + +echo $'Tab:\there' +``` + +## Options + +This rule has no options. + +## When Not to Use It + +Sometimes single quotes are used precisely to delay expansion, for example `trap 'rm -f $tmpfile' EXIT` or `sh -c 'echo $HOME'`. This rule is set to `"warn"` in the recommended configuration for that reason. If your scripts frequently pass shell code as strings, you can disable this rule or use a disable comment for those lines. + +## Prior Art + +- [SC2016](https://www.shellcheck.net/wiki/SC2016) diff --git a/src/index.spec.ts b/src/index.spec.ts index 8af1c9a..f31cdac 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -42,7 +42,10 @@ describe("plugin", () => { it("should expose all rules", () => { const ruleIds = Object.keys(plugin.rules); - expect(ruleIds.sort()).toEqual(["no-backticks"]); + expect(ruleIds.sort()).toEqual([ + "no-backticks", + "no-expansions-in-single-quotes", + ]); }); it("should give every rule meta docs and messages", () => { diff --git a/src/index.ts b/src/index.ts index dd058c4..5b83d97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,11 @@ //------------------------------------------------------------------------------ import { ShellLanguage } from "./languages/shell-language.js"; import noBackticks from "./rules/no-backticks.js"; +import noExpansionsInSingleQuotes from "./rules/no-expansions-in-single-quotes.js"; const rules = { "no-backticks": noBackticks, + "no-expansions-in-single-quotes": noExpansionsInSingleQuotes, }; const plugin = { @@ -34,6 +36,7 @@ const plugin = { plugins: {}, rules: { "shell/no-backticks": "error", + "shell/no-expansions-in-single-quotes": "warn", }, }, }, diff --git a/src/rules/no-expansions-in-single-quotes.spec.ts b/src/rules/no-expansions-in-single-quotes.spec.ts new file mode 100644 index 0000000..c7260b0 --- /dev/null +++ b/src/rules/no-expansions-in-single-quotes.spec.ts @@ -0,0 +1,55 @@ +/** + * @fileoverview Tests for the no-expansions-in-single-quotes rule. + */ + +import { RuleTester } from "eslint"; +import { ShellLanguage } from "../languages/shell-language.js"; +import rule from "./no-expansions-in-single-quotes.js"; + +const ruleTester = new RuleTester({ + plugins: { + 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-expansions-in-single-quotes", rule as never, { + valid: [ + "echo 'plain text'", + 'echo "$var"', + // $1-style positional references are common in awk/sed programs + "awk '{print $1}' file", + // A lone dollar sign is not an expansion + "echo 'costs $5'", + // $'...' is an intentional escape string + "echo $'tab\\there'", + ], + invalid: [ + { + code: "echo '$var'", + errors: [ + { + messageId: "expansionInSingleQuotes", + line: 1, + column: 6, + endColumn: 12, + }, + ], + }, + { + code: "echo '${var}'", + errors: [{ messageId: "expansionInSingleQuotes" }], + }, + { + code: "echo '$(pwd)'", + errors: [{ messageId: "expansionInSingleQuotes" }], + }, + { + code: "echo 'run `date` now'", + errors: [{ messageId: "expansionInSingleQuotes" }], + }, + ], +}); diff --git a/src/rules/no-expansions-in-single-quotes.ts b/src/rules/no-expansions-in-single-quotes.ts new file mode 100644 index 0000000..eb9a278 --- /dev/null +++ b/src/rules/no-expansions-in-single-quotes.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview Rule to flag expansion-like text inside single quotes. + * Mirrors ShellCheck SC2016. + */ + +import type { ShellRuleDefinition } from "../types.js"; + +/** + * Matches text that looks like an intended expansion: `$name`, `${...}`, + * `$(...)`, or backticks. Positional-style references such as `$1` are + * excluded because they are common in awk/sed programs. + */ +const EXPANSION_LIKE = /\$[A-Za-z_{(]|`[^`]+`/u; + +const rule: ShellRuleDefinition<{ MessageIds: "expansionInSingleQuotes" }> = { + meta: { + type: "suggestion", + docs: { + description: + "Disallow expansion-like syntax inside single quotes, where it is not expanded", + recommended: true, + url: "https://github.com/eslint/shell/blob/main/docs/rules/no-expansions-in-single-quotes.md", + }, + schema: [], + messages: { + expansionInSingleQuotes: + "Expressions don't expand in single quotes; use double quotes for that. (ShellCheck SC2016)", + }, + }, + + create(context) { + return { + SingleQuotedString(node) { + if (!node.dollar && EXPANSION_LIKE.test(node.value)) { + context.report({ + node, + messageId: "expansionInSingleQuotes", + }); + } + }, + }; + }, +}; + +export default rule;