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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default [
language: "shell/bash",
rules: {
"shell/no-backticks": "error",
"shell/no-unquoted-expansions": "error",
},
},
];
Expand Down Expand Up @@ -84,10 +85,11 @@ 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 |
| [`no-expansions-in-single-quotes`](./docs/rules/no-expansions-in-single-quotes.md) | SC2016 | Expressions don't expand in single quotes | | warn |
| 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 |
| [`no-unquoted-expansions`](./docs/rules/no-unquoted-expansions.md) | SC2086/46 | Quote expansions subject to word splitting and globbing | ✅ | error |

## Configuration comments

Expand Down
79 changes: 79 additions & 0 deletions docs/rules/no-unquoted-expansions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# no-unquoted-expansions

Require quoting parameter expansions and command substitutions that are subject to word splitting.

## Background

When an expansion such as `$file` or `$(cmd)` is not quoted, the shell splits its value on whitespace and then expands any glob characters in the pieces. A file named `my report.txt` becomes two arguments, and a value containing `*` can turn into a list of unrelated files. Wrapping the expansion in double quotes (`"$file"`) passes the value through as a single argument.

## Rule Details

This rule warns about unquoted parameter expansions (`$var`, `${var}`, `$@`, `$1`, and so on) and command substitutions (`$(...)` and backticks) in positions where word splitting happens:

- a command's name and arguments, including arguments to `[ ... ]`;
- the word list of a `for ... in` loop;
- redirection targets, such as `> $file`, except when the `variant` language option is `"posix"`.

The rule does not warn about:

- expansions inside double quotes;
- parameters that can't contain whitespace: `$?`, `$$`, `$!`, `$#`, `$-`, and length expansions such as `${#array[@]}`;
- contexts where the shell doesn't split words: variable assignments (`x=$y`), `[[ ... ]]`, `case` subjects, arithmetic such as `$((...))`, and heredoc or herestring redirects (`<<`, `<<-`, `<<<`);
- redirection targets when the `variant` language option is `"posix"`, because POSIX `sh` doesn't split them.

This rule is autofixable when the expansion is the entire word: `$var` becomes `"$var"`. Words that mix an expansion with other text, such as `prefix$var`, are reported but not fixed.

Examples of **incorrect** code for this rule:

```bash
# eslint shell/no-unquoted-expansions: "error"

rm $file

cp $source $destination

for f in $files; do echo "$f"; done

echo $(ls)

sort data.txt > $output

$command --verbose
```

Examples of **correct** code for this rule:

```bash
# eslint shell/no-unquoted-expansions: "error"

rm "$file"

cp "$source" "$destination"

for f in "$@"; do echo "$f"; done

echo "$(ls)"

name=$other

[[ $a == "$b" ]]

echo $? $# ${#items[@]}

echo $((count + 1))

cat <<< $input
```

## Options

This rule has no options.

## When Not to Use It

Occasionally word splitting is intended, such as passing a space-separated list of flags stored in a variable. Prefer an array (`"${flags[@]}"`) in that case; otherwise, use a disable comment for the specific line rather than disabling this rule.

## Prior Art

- [SC2086](https://www.shellcheck.net/wiki/SC2086)
- [SC2046](https://www.shellcheck.net/wiki/SC2046)
1 change: 1 addition & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("plugin", () => {
expect(ruleIds.sort()).toEqual([
"no-backticks",
"no-expansions-in-single-quotes",
"no-unquoted-expansions",
]);
});

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
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";

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

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

import { RuleTester } from "eslint";
import { ShellLanguage } from "../languages/shell-language.js";
import rule from "./no-unquoted-expansions.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-unquoted-expansions", rule as never, {
valid: [
// Quoted expansions
'echo "$var"',
'echo "${var}"',
'echo "$(pwd)"',
'cp "$src" "$dest"',
'for f in "$@"; do echo "$f"; done',
// Safe special parameters
"echo $?",
"echo $$",
"echo $#",
"echo $!",
"echo $-",
"echo ${#arr}",
// Assignments don't word-split
"x=$y",
"x=$(pwd)",
// [[ ]] doesn't word-split
"[[ $x == foo ]]",
// Case discriminants don't word-split
"case $x in a) ;; esac",
// Arithmetic contexts don't word-split
"echo $((x + 1))",
// Heredoc delimiters and herestrings
"cat <<< $var",
// POSIX sh doesn't word-split redirection targets
{
code: "cat > $out",
languageOptions: { variant: "posix" },
},
{
code: "cat < $(pwd)/in",
languageOptions: { variant: "posix" },
},
],
invalid: [
{
code: "echo $var",
output: 'echo "$var"',
errors: [
{
messageId: "unquotedParameterExpansion",
data: { expansion: "$var" },
line: 1,
column: 6,
endColumn: 10,
},
],
},
{
code: "echo ${var}",
output: 'echo "${var}"',
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
// The message quotes the expansion as written
code: "echo ${arr[@]}",
output: 'echo "${arr[@]}"',
errors: [
{
messageId: "unquotedParameterExpansion",
data: { expansion: "${arr[@]}" },
},
],
},
{
code: "echo ${var:-default}",
output: 'echo "${var:-default}"',
errors: [
{
messageId: "unquotedParameterExpansion",
data: { expansion: "${var:-default}" },
},
],
},
{
code: "echo $@",
output: 'echo "$@"',
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
code: "echo $1",
output: 'echo "$1"',
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
code: "rm $(ls)",
output: 'rm "$(ls)"',
errors: [{ messageId: "unquotedCommandSubstitution" }],
},
{
// Expansion in the command-name position
code: "$cmd --help",
output: '"$cmd" --help',
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
// Mixed word: report but do not autofix
code: "echo prefix$var",
output: null,
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
code: "for f in $files; do echo ok; done",
output: 'for f in "$files"; do echo ok; done',
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
code: "cat > $out",
output: 'cat > "$out"',
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
code: "cat > $out",
output: 'cat > "$out"',
languageOptions: { variant: "mksh" },
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
// Arguments still word-split in POSIX sh
code: "cat $in > $out",
output: 'cat "$in" > $out',
languageOptions: { variant: "posix" },
errors: [{ messageId: "unquotedParameterExpansion" }],
},
{
code: "cp $a $b",
output: 'cp "$a" "$b"',
errors: [
{ messageId: "unquotedParameterExpansion" },
{ messageId: "unquotedParameterExpansion" },
],
},
],
});
Loading
Loading