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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Each rule mirrors a well-known ShellCheck check.
| [`no-unquoted-expansions`](./docs/rules/no-unquoted-expansions.md) | SC2086/46 | Quote expansions subject to word splitting and globbing | ✅ | error |
| [`no-useless-cat`](./docs/rules/no-useless-cat.md) | SC2002 | Don't pipe from a single-file `cat` | | error |
| [`no-useless-echo`](./docs/rules/no-useless-echo.md) | SC2116 | Disallow `$(echo ...)` | | error |
| [`require-read-r`](./docs/rules/require-read-r.md) | SC2162 | Use `read -r` so backslashes aren't mangled | ✅ | error |

## Configuration comments

Expand Down
51 changes: 51 additions & 0 deletions docs/rules/require-read-r.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# require-read-r

Require `read -r` so backslashes are not mangled.

## Background

By default, `read` treats backslashes in its input as escape characters: it removes them and joins lines that end in a backslash. Input such as `C:\temp` or a line ending in `\` is silently changed. The `-r` option makes `read` keep backslashes as they are, which is almost always what you want.

## Rule Details

This rule warns about `read` commands that don't pass the `-r` option. The option can appear alone (`-r`) or combined with other short options (`-rs`), anywhere before `--`.

This rule is autofixable: it inserts `-r` immediately after `read`.

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

```bash
# eslint shell/require-read-r: "error"

read line

read -p "Name: " name

while read line; do echo "$line"; done < input.txt
```

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

```bash
# eslint shell/require-read-r: "error"

read -r line

read -r -p "Name: " name

read -rs password

while IFS= read -r line; do echo "$line"; done < input.txt
```

## Options

This rule has no options.

## When Not to Use It

If you intentionally want `read` to interpret backslash escapes, for example to allow line continuations in input, you can disable this rule for those lines.

## Prior Art

- [SC2162](https://www.shellcheck.net/wiki/SC2162)
1 change: 1 addition & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe("plugin", () => {
"no-unquoted-expansions",
"no-useless-cat",
"no-useless-echo",
"require-read-r",
]);
});

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import noLsIteration from "./rules/no-ls-iteration.js";
import noUnquotedExpansions from "./rules/no-unquoted-expansions.js";
import noUselessCat from "./rules/no-useless-cat.js";
import noUselessEcho from "./rules/no-useless-echo.js";
import requireReadR from "./rules/require-read-r.js";

//------------------------------------------------------------------------------
// Plugin
Expand All @@ -27,6 +28,7 @@ const rules = {
"no-unquoted-expansions": noUnquotedExpansions,
"no-useless-cat": noUselessCat,
"no-useless-echo": noUselessEcho,
"require-read-r": requireReadR,
};

const plugin = {
Expand Down Expand Up @@ -54,6 +56,7 @@ const plugin = {
"shell/no-unquoted-expansions": "error",
"shell/no-useless-cat": "error",
"shell/no-useless-echo": "error",
"shell/require-read-r": "error",
},
},
},
Expand Down
72 changes: 72 additions & 0 deletions src/rules/require-read-r.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @fileoverview Tests for the require-read-r rule.
* @author Claude Opus 5.5
*/

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

import { RuleTester } from "eslint";
import { ShellLanguage } from "../languages/shell-language.js";
import rule from "./require-read-r.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("require-read-r", rule as never, {
valid: [
"read -r line",
"read -rs password",
"read -r -a parts",
'while read -r line; do echo "$line"; done < file',
// Not the read builtin
"gread line",
],
invalid: [
{
code: "read line",
output: "read -r line",
errors: [
{
messageId: "missingR",
line: 1,
column: 1,
endColumn: 5,
},
],
},
{
code: "read -s password",
output: "read -r -s password",
errors: [{ messageId: "missingR" }],
},
{
code: "read -d -r value",
output: "read -r -d -r value",
errors: [{ messageId: "missingR" }],
},
{
code: "read -dr value",
output: "read -r -dr value",
errors: [{ messageId: "missingR" }],
},
{
code: 'while read line; do echo "$line"; done < file',
output: 'while read -r line; do echo "$line"; done < file',
errors: [{ messageId: "missingR" }],
},
],
});
115 changes: 115 additions & 0 deletions src/rules/require-read-r.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @fileoverview Rule to require the `-r` flag when using `read`.
* Mirrors ShellCheck SC2162.
* @author Claude Opus 5.5
*/

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

import { getCommandName, getStaticText } from "./utils.js";
import type { ShellRuleDefinition } from "../types.js";

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

const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]);

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

const rule: ShellRuleDefinition<{ MessageIds: "missingR" }> = {
meta: {
type: "problem",
languages: ["shell/bash", "shell/posix", "shell/mksh"],
docs: {
description: "Require `read -r` so backslashes are not mangled",
recommended: true,
dialects: ["Bash", "POSIX sh", "mksh"],
url: "https://github.com/eslint/shell/blob/main/docs/rules/require-read-r.md",
},
fixable: "code",
schema: [],
messages: {
missingR:
"read without -r will mangle backslashes. (ShellCheck SC2162)",
},
},

create(context) {
return {
Command(node) {
if (getCommandName(node) !== "read" || !node.name) {
return;
}

let hasRawOption = false;

for (let index = 0; index < node.arguments.length; index++) {
const argument = node.arguments[index];
if (!argument) {
continue;
}

const text = getStaticText(argument);
if (text === "--") {
break;
}

if (
text === null ||
!text.startsWith("-") ||
text === "-"
) {
continue;
}

for (
let optionIndex = 1;
optionIndex < text.length;
optionIndex++
) {
const option = text[optionIndex];
if (!option) {
break;
}

if (option === "r") {
hasRawOption = true;
break;
}

if (optionsWithArguments.has(option)) {
if (optionIndex === text.length - 1) {
index++;
}

break;
}
}

if (hasRawOption) {
break;
}
}

if (hasRawOption) {
return;
}

const name = node.name;

context.report({
node: name,
messageId: "missingR",
fix: fixer => fixer.insertTextAfter(name, " -r"),
});
},
};
},
};

export default rule;
8 changes: 8 additions & 0 deletions tests/autofix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,12 @@ describe("autofix", () => {

expect(result.output).toBe('cp "$src" "$dest"\n');
});

it("should add -r to read", () => {
const result = fix('read line\necho "$line"\n', {
"shell/require-read-r": "error",
});

expect(result.output).toBe('read -r line\necho "$line"\n');
});
});
Loading