Skip to content
Open
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ to override the dialect of the language you chose.

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

Expand Down
57 changes: 57 additions & 0 deletions docs/rules/no-expansions-in-single-quotes.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@

import { ShellLanguage } from "./languages/shell-language.js";
import noBackticks from "./rules/no-backticks.js";
import noExpansionsInSingleQuotes from "./rules/no-expansions-in-single-quotes.js";

//------------------------------------------------------------------------------
// Plugin
//------------------------------------------------------------------------------

const rules = {
"no-backticks": noBackticks,
"no-expansions-in-single-quotes": noExpansionsInSingleQuotes,
};

const plugin = {
Expand All @@ -39,6 +41,7 @@ const plugin = {
plugins: {},
rules: {
"shell/no-backticks": "error",
"shell/no-expansions-in-single-quotes": "warn",
},
},
},
Expand Down
65 changes: 65 additions & 0 deletions src/rules/no-expansions-in-single-quotes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @fileoverview Tests for the no-expansions-in-single-quotes rule.
* @author Claude Opus 5.5
*/

//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------

import { RuleTester } from "eslint";
import { ShellLanguage } from "../languages/shell-language.js";
import rule from "./no-expansions-in-single-quotes.js";

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

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-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" }],
},
],
});
60 changes: 60 additions & 0 deletions src/rules/no-expansions-in-single-quotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @fileoverview Rule to flag expansion-like text inside single quotes.
* Mirrors ShellCheck SC2016.
* @author Claude Opus 5.5
*/

//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------

import type { ShellRuleDefinition } from "../types.js";

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/**
* 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;
Comment thread
nzakas marked this conversation as resolved.

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

const rule: ShellRuleDefinition<{ MessageIds: "expansionInSingleQuotes" }> = {
meta: {
type: "suggestion",
languages: ["shell/bash", "shell/posix", "shell/mksh"],
docs: {
description:
"Disallow expansion-like syntax inside single quotes, where it is not expanded",
recommended: true,
dialects: ["Bash", "POSIX sh", "mksh"],
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;
Loading