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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,49 @@ 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 bash from "@eslint/bash";

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

### Language options

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

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

## Configuration comments

Standard ESLint configuration comments work inside Bash files:

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

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

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

## Syntax tree

The tree format is documented in [docs/syntax-tree.md](docs/syntax-tree.md).
Expand Down
34 changes: 34 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, { BashSyntaxError, parseBash } from "./index.js";
import { BashLanguage } from "./languages/bash-language.js";

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

it("should expose the bash language", () => {
expect(plugin.languages.bash).toBeInstanceOf(BashLanguage);
});

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("bash", plugin);
});

it("should use the bash language for shell files", () => {
expect(recommended.language).toBe("bash/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 => `bash/${ruleId}`)
.sort();

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

import { BashLanguage } from "./languages/bash-language.js";

const rules = {};

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

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

export default plugin;
export { BashLanguage } from "./languages/bash-language.js";
export { BashSourceCode } from "./languages/bash-source-code.js";
export { parseBash, BashSyntaxError } from "./parser/parse.js";
export { visitorKeys } from "./visitor-keys.js";
export type * from "./types.js";
118 changes: 118 additions & 0 deletions src/languages/bash-language.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @fileoverview Unit tests for BashLanguage.
*/

import { describe, expect, it } from "vitest";
import { BashLanguage } from "./bash-language.js";
import { BashSourceCode } from "./bash-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("BashLanguage", () => {
const language = new BashLanguage();

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("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 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 BashSourceCode", () => {
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(BashSourceCode);
expect(sourceCode.text).toBe("echo hi\n");
}
});
});
});
111 changes: 111 additions & 0 deletions src/languages/bash-language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* @fileoverview The BashLanguage class, the ESLint Language implementation
* for Bash files.
*/

import type {
File,
Language,
LanguageContext,
OkParseResult,
ParseResult,
} from "@eslint/core";
import { BashSyntaxError, parseBash } from "../parser/parse.js";
import { BashSourceCode } from "./bash-source-code.js";
import { visitorKeys } from "../visitor-keys.js";
import type {
BashLanguageOptions,
BashNode,
CommentNode,
ProgramNode,
} from "../types.js";

const SHELL_VARIANTS = new Set(["bash", "posix", "mksh"]);

export type BashOkParseResult = OkParseResult<ProgramNode> & {
comments: CommentNode[];
};

/**
* ESLint Language implementation for Bash.
*/
export class BashLanguage implements Language<{
LangOptions: BashLanguageOptions;
Code: BashSourceCode;
RootNode: ProgramNode;
Node: BashNode;
}> {
fileType = "text" as const;
lineStart = 1 as const;
columnStart = 1 as const;
nodeTypeKey = "type";
visitorKeys = visitorKeys;

defaultLanguageOptions: BashLanguageOptions = {
variant: "bash",
};

validateLanguageOptions(languageOptions: BashLanguageOptions): void {
if (
languageOptions.variant !== undefined &&
!SHELL_VARIANTS.has(languageOptions.variant as string)
) {
throw new TypeError(
`Invalid shell variant "${String(languageOptions.variant)}". Expected "bash", "posix", or "mksh".`,
);
}
}

parse(
file: File,
context?: LanguageContext<BashLanguageOptions>,
): ParseResult<ProgramNode> {
const text = file.body as string;

try {
const { ast, comments } = parseBash(text, {
variant: context?.languageOptions?.variant,
path: file.path,
});

return { ok: true, ast, comments };
} catch (error) {
if (error instanceof BashSyntaxError) {
return {
ok: false,
errors: [
{
message: error.message,
line: error.line,
column: error.column,
},
],
};
}

return {
ok: false,
errors: [
{
message:
error instanceof Error
? error.message
: String(error),
line: 1,
column: 1,
},
],
};
}
}

createSourceCode(
file: File,
parseResult: BashOkParseResult,
): BashSourceCode {
return new BashSourceCode({
text: file.body as string,
ast: parseResult.ast,
});
}
}
Loading
Loading