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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ export default [
];
```

Or configure it manually:

```js
import shell from "@eslint/shell";

export default [
{
files: ["**/*.sh"],
plugins: { shell },
language: "shell/bash",
rules: {
"shell/no-backticks": "error",
},
},
];
```

### Languages

The plugin provides one language per shell dialect:
Expand All @@ -46,10 +63,19 @@ export default [
files: ["**/*.sh"],
plugins: { shell },
language: "shell/posix",
rules: { "shell/no-backticks": "error" },
},
];
```

## Rules

Each rule mirrors a well-known ShellCheck check.

| Rule | ShellCheck | Description | Fixable | Recommended |
| ---------------------------------------------- | ---------- | ---------------------------------------- | ------- | ----------- |
| [`no-backticks`](./docs/rules/no-backticks.md) | SC2006 | Use `$(...)` instead of legacy backticks | ✅ | error |

## Configuration comments

Standard ESLint configuration comments work inside shell scripts:
Expand Down
57 changes: 57 additions & 0 deletions docs/rules/no-backticks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# no-backticks

Disallow legacy backtick command substitution in favor of `$(...)`.

## Background

Shells support two syntaxes for command substitution: the legacy backtick form (`` `cmd` ``) and the POSIX `$(cmd)` form. The backtick form is harder to read and harder to get right:

- Nesting requires escaping the inner backticks (`` `outer \`inner\`` ``).
- Backslashes inside backticks are processed differently than elsewhere, which makes quoting surprising.
- Backticks are easy to confuse with single quotes.

`$(...)` nests without escaping and treats its contents like any other code.

## Rule Details

This rule warns on every backtick command substitution, including ones inside double-quoted strings. Nested backtick substitutions are reported individually.

This rule is autofixable: it rewrites `` `cmd` `` as `$(cmd)`. Substitutions that contain a backslash or a nested backtick are reported but not fixed, because backslash escaping inside backticks differs from `$(...)` and a textual rewrite could change the meaning.

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

```bash
# eslint shell/no-backticks: "error"

today=`date +%F`

echo "Now in `pwd`"

for f in `find . -name '*.sh'`; do echo "$f"; done
```

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

```bash
# eslint shell/no-backticks: "error"

today=$(date +%F)

echo "Now in $(pwd)"

parent=$(basename "$(dirname "$PWD")")

echo 'Backticks in single quotes, like `this`, are just text'
```

## Options

This rule has no options.

## When Not to Use It

If your scripts must run on very old Bourne shells that predate POSIX `$(...)` support, you can safely disable this rule.

## Prior Art

- [SC2006](https://www.shellcheck.net/wiki/SC2006)
15 changes: 14 additions & 1 deletion src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,20 @@ describe("plugin", () => {
it("should expose all rules", () => {
const ruleIds = Object.keys(plugin.rules);

expect(ruleIds.sort()).toEqual([]);
expect(ruleIds.sort()).toEqual(["no-backticks"]);
});

it("should give every rule meta docs and messages", () => {
for (const [ruleId, rule] of Object.entries(plugin.rules)) {
expect(rule.meta?.docs?.description, ruleId).toBeTruthy();
expect(rule.meta?.docs?.recommended, ruleId).toBe(true);
expect(rule.meta?.messages, ruleId).toBeTruthy();
expect(rule.meta?.schema, ruleId).toBeDefined();
expect(rule.meta?.docs?.url, ruleId).toBe(
`https://github.com/eslint/shell/blob/main/docs/rules/${ruleId}.md`,
);
expect(typeof rule.create, ruleId).toBe("function");
}
});

describe("recommended config", () => {
Expand Down
9 changes: 7 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
*/

import { ShellLanguage } from "./languages/shell-language.js";
import noBackticks from "./rules/no-backticks.js";

const rules = {};
const rules = {
"no-backticks": noBackticks,
};

const plugin = {
meta: {
Expand All @@ -24,7 +27,9 @@ const plugin = {
files: ["**/*.sh", "**/*.bash"],
language: "shell/bash",
plugins: {},
rules: {},
rules: {
"shell/no-backticks": "error",
},
},
},
};
Expand Down
61 changes: 61 additions & 0 deletions src/rules/no-backticks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @fileoverview Tests for the no-backticks rule.
*/

import { RuleTester } from "eslint";
import { ShellLanguage } from "../languages/shell-language.js";
import rule from "./no-backticks.js";

const ruleTester = new RuleTester({
plugins: {
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-backticks", rule as never, {
valid: [
"echo $(pwd)",
'echo "$(date)"',
"x=$(ls | wc -l)",
"echo plain text",
"echo 'literal `backticks` in single quotes are text? no...'",
],
invalid: [
{
code: "echo `pwd`",
output: "echo $(pwd)",
errors: [
{
messageId: "useDollarParen",
line: 1,
column: 6,
endColumn: 11,
},
],
},
{
code: "x=`date +%s`",
output: "x=$(date +%s)",
errors: [{ messageId: "useDollarParen" }],
},
{
code: 'echo "today is `date`"',
output: 'echo "today is $(date)"',
errors: [{ messageId: "useDollarParen" }],
},
{
// Backslashes inside backticks change meaning, so no autofix.
// Both the outer and the nested substitution are reported.
code: "echo `echo \\`x\\``",
output: null,
errors: [
{ messageId: "useDollarParen" },
{ messageId: "useDollarParen" },
],
},
],
});
58 changes: 58 additions & 0 deletions src/rules/no-backticks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @fileoverview Rule to disallow legacy backtick command substitution.
* Mirrors ShellCheck SC2006.
*/

import type { ShellRuleDefinition } from "../types.js";

const rule: ShellRuleDefinition<{ MessageIds: "useDollarParen" }> = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow legacy backtick command substitution in favor of `$(...)`",
recommended: true,
url: "https://github.com/eslint/shell/blob/main/docs/rules/no-backticks.md",
},
fixable: "code",
schema: [],
messages: {
useDollarParen:
"Use $(...) notation instead of legacy backticks. (ShellCheck SC2006)",
},
},

create(context) {
const { sourceCode } = context;

return {
CommandSubstitution(node) {
if (!node.backquotes) {
return;
}

const [start, end] = sourceCode.getRange(node);
const inner = sourceCode.text.slice(start + 1, end - 1);

context.report({
node,
messageId: "useDollarParen",

// Escapes behave differently inside backticks, so only
// fix substitutions without backslashes or nesting.
fix: /[\\`]/u.test(inner)
? undefined
: fixer => [
fixer.replaceTextRange(
[start, start + 1],
"$(",
),
fixer.replaceTextRange([end - 1, end], ")"),
],
});
},
};
},
};

export default rule;
36 changes: 36 additions & 0 deletions tests/autofix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @fileoverview Integration tests for autofixing through the Linter API.
*/

import { describe, expect, it } from "vitest";
import { Linter } from "eslint";
import shell from "../src/index.js";

function fix(code: string, rules: Record<string, unknown>): Linter.FixReport {
const linter = new Linter();

return linter.verifyAndFix(
code,
[
{
files: ["**/*.sh"],
plugins: { shell },
language: "shell/bash",
rules: rules as never,
},
] as never,
"script.sh",
);
}

describe("autofix", () => {
it("should fix backticks to $()", () => {
const result = fix("echo `pwd` `date`\n", {
"shell/no-backticks": "error",
});

expect(result.fixed).toBe(true);
expect(result.output).toBe("echo $(pwd) $(date)\n");
expect(result.messages).toEqual([]);
});
});
Loading
Loading