diff --git a/README.md b/README.md index 36ad35d..e21aaa7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Each rule mirrors a well-known ShellCheck check. | [`no-expansions-in-single-quotes`](./docs/rules/no-expansions-in-single-quotes.md) | SC2016 | Expressions don't expand in single quotes | | warn | | [`no-ls-iteration`](./docs/rules/no-ls-iteration.md) | SC2045 | Don't iterate over `ls` output; use globs | | error | | [`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 | ## Configuration comments diff --git a/docs/rules/no-useless-cat.md b/docs/rules/no-useless-cat.md new file mode 100644 index 0000000..2f43290 --- /dev/null +++ b/docs/rules/no-useless-cat.md @@ -0,0 +1,49 @@ +# no-useless-cat + +Disallow useless `cat` at the start of a pipeline. + +## Background + +`cat file | cmd` starts an extra process only to copy a file into a pipe. Most commands accept a filename argument (`cmd file`), and every command can read from a redirect (`cmd < file`). Either form is simpler and avoids the extra process. + +## Rule Details + +This rule warns when the first command of a pipeline is `cat` with exactly one filename argument and no redirects. + +The rule doesn't warn when `cat` actually does something, such as concatenating several files or using options like `-n`. It also skips arguments that aren't static text, such as `cat "$file"`. + +Examples of **incorrect** code for this rule: + +```bash +# eslint shell/no-useless-cat: "error" + +cat file.txt | grep error + +cat /var/log/syslog | tail -n 20 | grep kernel +``` + +Examples of **correct** code for this rule: + +```bash +# eslint shell/no-useless-cat: "error" + +grep error file.txt + +grep error < file.txt + +cat header.txt body.txt | gzip > out.gz + +cat -n file.txt | head +``` + +## Options + +This rule has no options. + +## When Not to Use It + +Some people prefer `cat file | cmd` because the data reads left to right. If you prefer that style, you can safely disable this rule. + +## Prior Art + +- [SC2002](https://www.shellcheck.net/wiki/SC2002) diff --git a/src/index.spec.ts b/src/index.spec.ts index f8167b2..347bc7f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -39,6 +39,7 @@ describe("plugin", () => { "no-expansions-in-single-quotes", "no-ls-iteration", "no-unquoted-expansions", + "no-useless-cat", "no-useless-echo", ]); }); diff --git a/src/index.ts b/src/index.ts index c71998b..f3c260e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import noBackticks from "./rules/no-backticks.js"; import noExpansionsInSingleQuotes from "./rules/no-expansions-in-single-quotes.js"; 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"; const rules = { @@ -15,6 +16,7 @@ const rules = { "no-expansions-in-single-quotes": noExpansionsInSingleQuotes, "no-ls-iteration": noLsIteration, "no-unquoted-expansions": noUnquotedExpansions, + "no-useless-cat": noUselessCat, "no-useless-echo": noUselessEcho, }; @@ -41,6 +43,7 @@ const plugin = { "shell/no-expansions-in-single-quotes": "warn", "shell/no-ls-iteration": "error", "shell/no-unquoted-expansions": "error", + "shell/no-useless-cat": "error", "shell/no-useless-echo": "error", }, }, diff --git a/src/rules/no-useless-cat.spec.ts b/src/rules/no-useless-cat.spec.ts new file mode 100644 index 0000000..ef72313 --- /dev/null +++ b/src/rules/no-useless-cat.spec.ts @@ -0,0 +1,69 @@ +/** + * @fileoverview Tests for the no-useless-cat rule. + */ + +import { RuleTester } from "eslint"; +import { ShellLanguage } from "../languages/shell-language.js"; +import rule from "./no-useless-cat.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-useless-cat", rule as never, { + valid: [ + // No pipeline at all + "cat file.txt", + // cat merging multiple files is legitimate + "cat a.txt b.txt | sort", + // cat with flags changes output + "cat -n file.txt | head", + // Unquoted glob and brace patterns can expand to multiple files. + "cat *.txt | sort", + "cat {a,b} | sort", + "cat {1..3} | sort", + "cat {a,{b}} | sort", + // ANSI-C quoting can transform source text into an option. + "cat $'\\x2dn' | head", + // pipeline not starting with cat + "sort file.txt | uniq | cat", + // cat with redirect input + "cat < file.txt | sort", + // dynamic file name still counts as one file, but command may + // legitimately be built dynamically; still reported? No: only + // static single-file cats are reported. + 'cat "$f" | sort', + ], + invalid: [ + { + code: "cat file.txt | grep foo", + errors: [ + { + messageId: "uselessCat", + line: 1, + column: 1, + endColumn: 13, + }, + ], + }, + { + code: "cat /var/log/syslog | tail -n 5 | grep error", + errors: [{ messageId: "uselessCat" }], + }, + { + code: 'cat "*.txt" | sort', + errors: [{ messageId: "uselessCat" }], + }, + { + code: "cat '{a,b}' | sort", + errors: [{ messageId: "uselessCat" }], + }, + ], +}); diff --git a/src/rules/no-useless-cat.ts b/src/rules/no-useless-cat.ts new file mode 100644 index 0000000..0d3d01a --- /dev/null +++ b/src/rules/no-useless-cat.ts @@ -0,0 +1,89 @@ +/** + * @fileoverview Rule to disallow piping from a single-file `cat`. + * Mirrors ShellCheck SC2002. + */ + +import { getCommandName, getStaticText } from "./utils.js"; +import type { ShellRuleDefinition, WordNode, WordPartNode } from "../types.js"; + +function hasPotentialExpansion(word: WordNode): boolean { + const checkParts = (parts: WordPartNode[], quoted = false): boolean => + parts.some(part => { + switch (part.type) { + case "Literal": + return ( + !quoted && + (part.value.includes("*") || + part.value.includes("?") || + part.value.includes("[") || + part.value.includes("{") || + part.value.includes("}")) + ); + case "SingleQuotedString": + return part.dollar; + case "DoubleQuotedString": + return checkParts(part.parts, true); + default: + return false; + } + }); + + return checkParts(word.parts); +} + +const rule: ShellRuleDefinition<{ MessageIds: "uselessCat" }> = { + meta: { + type: "suggestion", + languages: ["shell/bash", "shell/posix", "shell/mksh"], + docs: { + description: "Disallow useless `cat` at the start of a pipeline", + recommended: true, + dialects: ["Bash", "POSIX sh", "mksh"], + url: "https://github.com/eslint/shell/blob/main/docs/rules/no-useless-cat.md", + }, + schema: [], + messages: { + uselessCat: + "Useless cat. Consider 'cmd < file' or 'cmd file' instead. (ShellCheck SC2002)", + }, + }, + + create(context) { + return { + Pipeline(node) { + const first = node.commands[0]; + + if ( + !first || + first.type !== "Command" || + getCommandName(first) !== "cat" || + first.redirects.length > 0 || + first.arguments.length !== 1 + ) { + return; + } + + const argument = first.arguments[0]; + const text = argument ? getStaticText(argument) : null; + + // Skip flags, stdin markers, and arguments that can expand to + // multiple words. + if ( + text === null || + text.startsWith("-") || + !argument || + hasPotentialExpansion(argument) + ) { + return; + } + + context.report({ + node: first, + messageId: "uselessCat", + }); + }, + }; + }, +}; + +export default rule;