From 741b77f7c8c55cdfd9bae1dde204f4bf388e6882 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 15:00:02 -0400 Subject: [PATCH 1/3] feat: add require-read-r rule Co-Authored-By: Claude Opus 5 --- README.md | 1 + docs/rules/require-read-r.md | 51 +++++++++++++++++++++++++++++ src/index.spec.ts | 1 + src/index.ts | 3 ++ src/rules/require-read-r.spec.ts | 52 +++++++++++++++++++++++++++++ src/rules/require-read-r.ts | 56 ++++++++++++++++++++++++++++++++ tests/autofix.test.ts | 8 +++++ 7 files changed, 172 insertions(+) create mode 100644 docs/rules/require-read-r.md create mode 100644 src/rules/require-read-r.spec.ts create mode 100644 src/rules/require-read-r.ts diff --git a/README.md b/README.md index b71cea8..98edc5d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,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 diff --git a/docs/rules/require-read-r.md b/docs/rules/require-read-r.md new file mode 100644 index 0000000..eb7d86b --- /dev/null +++ b/docs/rules/require-read-r.md @@ -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 bash/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 bash/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) diff --git a/src/index.spec.ts b/src/index.spec.ts index 4e75848..cef2530 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -31,6 +31,7 @@ describe("plugin", () => { "no-unquoted-expansions", "no-useless-cat", "no-useless-echo", + "require-read-r", ]); }); diff --git a/src/index.ts b/src/index.ts index 4e46cf0..bf328ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,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"; const rules = { "no-backticks": noBackticks, @@ -18,6 +19,7 @@ const rules = { "no-unquoted-expansions": noUnquotedExpansions, "no-useless-cat": noUselessCat, "no-useless-echo": noUselessEcho, + "require-read-r": requireReadR, }; const plugin = { @@ -43,6 +45,7 @@ const plugin = { "bash/no-unquoted-expansions": "error", "bash/no-useless-cat": "error", "bash/no-useless-echo": "error", + "bash/require-read-r": "error", }, }, }, diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts new file mode 100644 index 0000000..b061428 --- /dev/null +++ b/src/rules/require-read-r.spec.ts @@ -0,0 +1,52 @@ +/** + * @fileoverview Tests for the require-read-r rule. + */ + +import { RuleTester } from "eslint"; +import { BashLanguage } from "../languages/bash-language.js"; +import rule from "./require-read-r.js"; + +const ruleTester = new RuleTester({ + plugins: { + bash: { + languages: { bash: new BashLanguage() }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin shape is validated by ESLint at runtime. + } as any, + language: "bash/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: 'while read line; do echo "$line"; done < file', + output: 'while read -r line; do echo "$line"; done < file', + errors: [{ messageId: "missingR" }], + }, + ], +}); diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts new file mode 100644 index 0000000..c30bf73 --- /dev/null +++ b/src/rules/require-read-r.ts @@ -0,0 +1,56 @@ +/** + * @fileoverview Rule to require the `-r` flag when using `read`. + * Mirrors ShellCheck SC2162. + */ + +import { getCommandName, getStaticText } from "./utils.js"; +import type { BashRuleDefinition } from "../types.js"; + +const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { + meta: { + type: "problem", + docs: { + description: "Require `read -r` so backslashes are not mangled", + recommended: true, + url: "https://github.com/eslint/bash/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; + } + + for (const argument of node.arguments) { + const text = getStaticText(argument); + + if (text === "--") { + break; + } + + if (text !== null && /^-[a-zA-Z]*r/u.test(text)) { + return; + } + } + + const name = node.name; + + context.report({ + node: name, + messageId: "missingR", + fix: fixer => fixer.insertTextAfter(name, " -r"), + }); + }, + }; + }, +}; + +export default rule; diff --git a/tests/autofix.test.ts b/tests/autofix.test.ts index 8af2d8f..d03686f 100644 --- a/tests/autofix.test.ts +++ b/tests/autofix.test.ts @@ -41,4 +41,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', { + "bash/require-read-r": "error", + }); + + expect(result.output).toBe('read -r line\necho "$line"\n'); + }); }); From c8e4d9fe53b15747e44c304229440f17da9e9a2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:19 +0000 Subject: [PATCH 2/3] fix: parse read option operands Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/rules/require-read-r.spec.ts | 10 ++++++ src/rules/require-read-r.ts | 52 +++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts index b061428..7eebfef 100644 --- a/src/rules/require-read-r.spec.ts +++ b/src/rules/require-read-r.spec.ts @@ -43,6 +43,16 @@ ruleTester.run("require-read-r", rule as never, { 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', diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index c30bf73..231b124 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -6,6 +6,8 @@ import { getCommandName, getStaticText } from "./utils.js"; import type { BashRuleDefinition } from "../types.js"; +const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); + const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem", @@ -29,18 +31,60 @@ const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { return; } - for (const argument of node.arguments) { - const text = getStaticText(argument); + 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 && /^-[a-zA-Z]*r/u.test(text)) { - return; + 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({ From 52bd15bdaaa2597d0d1e7edd576f52d7014bb832 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Oct 2026 15:34:50 -0400 Subject: [PATCH 3/3] fix: add languages and dialects to require-read-r meta Co-Authored-By: Claude Opus 5.5 --- src/rules/require-read-r.spec.ts | 1 + src/rules/require-read-r.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/rules/require-read-r.spec.ts b/src/rules/require-read-r.spec.ts index 7eebfef..f64dd54 100644 --- a/src/rules/require-read-r.spec.ts +++ b/src/rules/require-read-r.spec.ts @@ -9,6 +9,7 @@ import rule from "./require-read-r.js"; const ruleTester = new RuleTester({ plugins: { bash: { + meta: { namespace: "shell" }, languages: { bash: new BashLanguage() }, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin shape is validated by ESLint at runtime. diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts index 231b124..34d2cd6 100644 --- a/src/rules/require-read-r.ts +++ b/src/rules/require-read-r.ts @@ -11,9 +11,11 @@ const optionsWithArguments = new Set(["a", "d", "i", "n", "N", "p", "t", "u"]); const rule: BashRuleDefinition<{ MessageIds: "missingR" }> = { meta: { type: "problem", + languages: ["shell/bash"], docs: { description: "Require `read -r` so backslashes are not mangled", recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], url: "https://github.com/eslint/bash/blob/main/docs/rules/require-read-r.md", }, fixable: "code",