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..f64dd54 --- /dev/null +++ b/src/rules/require-read-r.spec.ts @@ -0,0 +1,63 @@ +/** + * @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: { + meta: { namespace: "shell" }, + 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: "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" }], + }, + ], +}); diff --git a/src/rules/require-read-r.ts b/src/rules/require-read-r.ts new file mode 100644 index 0000000..34d2cd6 --- /dev/null +++ b/src/rules/require-read-r.ts @@ -0,0 +1,102 @@ +/** + * @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 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", + 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; 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'); + }); });