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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,64 @@ npm install --save-dev eslint @eslint/shell

Requires Node.js `^20.19.0 || ^22.13.0 || >=24`. Tested with ESLint v10.

## Usage

Add the plugin to your `eslint.config.js`:

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

export default [
// use the recommended rules for *.sh and *.bash files
shell.configs.recommended,
];
```

### Languages

The plugin provides one language per shell dialect:

| Language | Dialect |
| ------------- | -------- |
| `shell/bash` | Bash |
| `shell/posix` | POSIX sh |
| `shell/mksh` | mksh |

The recommended configuration uses `shell/bash`. To lint scripts written for
another dialect, set `language` yourself:

```js
export default [
{
files: ["**/*.sh"],
plugins: { shell },
language: "shell/posix",
},
];
```

### Language options

| Option | Values | Default | Description |
| --------- | ----------------------------- | ---------------------- | -------------------------- |
| `variant` | `"bash"`, `"posix"`, `"mksh"` | The language's dialect | The shell dialect to parse |

Each language sets `variant` to its own dialect, so you only need this option
to override the dialect of the language you chose.

## Configuration comments

Standard ESLint configuration comments work inside shell scripts:

```bash
# eslint-disable-next-line shell/no-backticks
echo `pwd`

echo `pwd` # eslint-disable-line shell/no-backticks -- legacy

# eslint shell/no-useless-echo: "warn"
```

## Syntax tree

The tree format is documented in [docs/syntax-tree.md](docs/syntax-tree.md).
Expand Down
43 changes: 43 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { describe, expect, it } from "vitest";
import plugin, { ShellSyntaxError, parseShell } from "./index.js";
import { ShellLanguage } from "./languages/shell-language.js";

describe("plugin", () => {
it("should expose plugin metadata", () => {
Expand All @@ -16,4 +17,46 @@ describe("plugin", () => {
expect(parseShell("echo hi\n").ast.type).toBe("Program");
expect(() => parseShell("if then fi\n")).toThrow(ShellSyntaxError);
});

it("should expose a language for each shell variant", () => {
expect(Object.keys(plugin.languages).sort()).toEqual([
"bash",
"mksh",
"posix",
]);

for (const [variant, language] of Object.entries(plugin.languages)) {
expect(language).toBeInstanceOf(ShellLanguage);
expect(language.defaultLanguageOptions).toEqual({ variant });
}
});

it("should expose all rules", () => {
const ruleIds = Object.keys(plugin.rules);

expect(ruleIds.sort()).toEqual([]);
});

describe("recommended config", () => {
const recommended = plugin.configs.recommended;

it("should reference the plugin itself", () => {
expect(recommended.plugins).toHaveProperty("shell", plugin);
});

it("should use the bash language for shell files", () => {
expect(recommended.language).toBe("shell/bash");
expect(recommended.files).toContain("**/*.sh");
expect(recommended.files).toContain("**/*.bash");
});

it("should configure every rule", () => {
const configured = Object.keys(recommended.rules).sort();
const expected = Object.keys(plugin.rules)
.map(ruleId => `shell/${ruleId}`)
.sort();

expect(configured).toEqual(expected);
});
});
});
25 changes: 25 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,39 @@
* ESLint, ShellCheck-inspired rules, and a recommended configuration.
*/

import { ShellLanguage } from "./languages/shell-language.js";

const rules = {};

const plugin = {
meta: {
name: "@eslint/shell",
namespace: "shell",
version: "0.0.0", // x-release-please-version
},
languages: {
bash: new ShellLanguage({ variant: "bash" }),
posix: new ShellLanguage({ variant: "posix" }),
mksh: new ShellLanguage({ variant: "mksh" }),
},
rules,
configs: {
recommended: {
name: "shell/recommended",
files: ["**/*.sh", "**/*.bash"],
language: "shell/bash",
plugins: {},
rules: {},
},
},
};

// The recommended config must reference the plugin itself.
Object.assign(plugin.configs.recommended.plugins, { shell: plugin });

export default plugin;
export { ShellLanguage } from "./languages/shell-language.js";
export { ShellSourceCode } from "./languages/shell-source-code.js";
export { parseShell, ShellSyntaxError } from "./parser/parse.js";
export { visitorKeys } from "./visitor-keys.js";
export type * from "./types.js";
160 changes: 160 additions & 0 deletions src/languages/shell-language.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @fileoverview Unit tests for ShellLanguage.
*/

import { describe, expect, it } from "vitest";
import { ShellLanguage } from "./shell-language.js";
import { ShellSourceCode } from "./shell-source-code.js";
import type { File } from "@eslint/core";

function createFile(body: string): File {
return {
path: "test.sh",
physicalPath: "test.sh",
bom: false,
body,
};
}

describe("ShellLanguage", () => {
const language = new ShellLanguage();

describe("metadata", () => {
it("should describe itself as a text language", () => {
expect(language.fileType).toBe("text");
expect(language.lineStart).toBe(1);
expect(language.columnStart).toBe(1);
expect(language.nodeTypeKey).toBe("type");
expect(language.visitorKeys).toHaveProperty("Program");
expect(language.defaultLanguageOptions).toEqual({
variant: "bash",
});
});
});

describe("constructor", () => {
it.each(["bash", "posix", "mksh"] as const)(
"should use the %s variant as the default language option",
variant => {
expect(
new ShellLanguage({ variant }).defaultLanguageOptions,
).toEqual({ variant });
},
);

it("should reject unknown variants", () => {
expect(
() =>
new ShellLanguage({
// @ts-expect-error -- testing invalid input
variant: "fish",
}),
).toThrow(TypeError);
});
});

describe("validateLanguageOptions", () => {
it("should accept valid variants", () => {
expect(() =>
language.validateLanguageOptions({ variant: "bash" }),
).not.toThrow();
expect(() =>
language.validateLanguageOptions({ variant: "posix" }),
).not.toThrow();
expect(() =>
language.validateLanguageOptions({ variant: "mksh" }),
).not.toThrow();
expect(() => language.validateLanguageOptions({})).not.toThrow();
});

it("should reject unknown variants", () => {
expect(() =>
language.validateLanguageOptions({
// @ts-expect-error -- testing invalid input
variant: "fish",
}),
).toThrow(TypeError);
});
});

describe("parse", () => {
it("should return ok with an AST for valid input", () => {
const result = language.parse(createFile("echo hi\n"));

expect(result.ok).toBe(true);

if (result.ok) {
expect(result.ast.type).toBe("Program");
}
});

it("should return errors with location for invalid input", () => {
const result = language.parse(createFile("echo ok\nif then fi\n"));

expect(result.ok).toBe(false);

if (!result.ok) {
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.line).toBe(2);
expect(result.errors[0]?.column).toBeGreaterThanOrEqual(1);
expect(result.errors[0]?.message.length).toBeGreaterThan(0);
}
});

it("should parse with the variant passed to the constructor", () => {
const file = createFile("diff <(sort a) <(sort b)\n");

expect(new ShellLanguage({ variant: "posix" }).parse(file).ok).toBe(
false,
);
expect(new ShellLanguage({ variant: "bash" }).parse(file).ok).toBe(
true,
);

// `|&` with no following command starts a coprocess in mksh only.
const coprocess = createFile("cat |&\n");

expect(
new ShellLanguage({ variant: "mksh" }).parse(coprocess).ok,
).toBe(true);
expect(
new ShellLanguage({ variant: "bash" }).parse(coprocess).ok,
).toBe(false);
});

it("should respect the variant language option", () => {
const posixResult = language.parse(
createFile("diff <(sort a) <(sort b)\n"),
{ languageOptions: { variant: "posix" } },
);

expect(posixResult.ok).toBe(false);

const bashResult = language.parse(
createFile("diff <(sort a) <(sort b)\n"),
{ languageOptions: { variant: "bash" } },
);

expect(bashResult.ok).toBe(true);
});
});

describe("createSourceCode", () => {
it("should create a ShellSourceCode", () => {
const file = createFile("echo hi\n");
const result = language.parse(file);

expect(result.ok).toBe(true);

if (result.ok) {
const sourceCode = language.createSourceCode(file, {
...result,
comments: [],
});

expect(sourceCode).toBeInstanceOf(ShellSourceCode);
expect(sourceCode.text).toBe("echo hi\n");
}
});
});
});
Loading
Loading