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 @@ -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
Expand Down
49 changes: 49 additions & 0 deletions docs/rules/no-useless-cat.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("plugin", () => {
"no-expansions-in-single-quotes",
"no-ls-iteration",
"no-unquoted-expansions",
"no-useless-cat",
"no-useless-echo",
]);
});
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ 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 = {
"no-backticks": noBackticks,
"no-expansions-in-single-quotes": noExpansionsInSingleQuotes,
"no-ls-iteration": noLsIteration,
"no-unquoted-expansions": noUnquotedExpansions,
"no-useless-cat": noUselessCat,
"no-useless-echo": noUselessEcho,
};

Expand All @@ -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",
},
},
Expand Down
69 changes: 69 additions & 0 deletions src/rules/no-useless-cat.spec.ts
Original file line number Diff line number Diff line change
@@ -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" }],
},
],
});
89 changes: 89 additions & 0 deletions src/rules/no-useless-cat.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
nzakas marked this conversation as resolved.

// 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;
Loading