From 0949e94c84fb4fc27258d29ea84ec5a871bbd878 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 10:35:27 -0400 Subject: [PATCH 1/2] feat: add Bash language Co-Authored-By: Claude Opus 5 --- README.md | 43 ++++ src/index.spec.ts | 34 ++++ src/index.ts | 23 +++ src/languages/bash-language.spec.ts | 118 +++++++++++ src/languages/bash-language.ts | 111 ++++++++++ src/languages/bash-source-code.spec.ts | 200 ++++++++++++++++++ src/languages/bash-source-code.ts | 271 +++++++++++++++++++++++++ src/types.spec.ts | 46 +++++ src/types.ts | 83 +++++++- src/visitor-keys.spec.ts | 80 ++++++++ src/visitor-keys.ts | 65 ++++++ tests/directives.test.ts | 116 +++++++++++ tests/fixtures/test-plugin.ts | 45 ++++ tests/plugin.test.ts | 138 +++++++++++++ 14 files changed, 1371 insertions(+), 2 deletions(-) create mode 100644 src/languages/bash-language.spec.ts create mode 100644 src/languages/bash-language.ts create mode 100644 src/languages/bash-source-code.spec.ts create mode 100644 src/languages/bash-source-code.ts create mode 100644 src/types.spec.ts create mode 100644 src/visitor-keys.spec.ts create mode 100644 src/visitor-keys.ts create mode 100644 tests/directives.test.ts create mode 100644 tests/fixtures/test-plugin.ts create mode 100644 tests/plugin.test.ts diff --git a/README.md b/README.md index 228d6e5..3e82b1d 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/index.spec.ts b/src/index.spec.ts index 9498e66..e8af10c 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -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", () => { @@ -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); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 61511d7..daf01c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/languages/bash-language.spec.ts b/src/languages/bash-language.spec.ts new file mode 100644 index 0000000..a6c06de --- /dev/null +++ b/src/languages/bash-language.spec.ts @@ -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"); + } + }); + }); +}); diff --git a/src/languages/bash-language.ts b/src/languages/bash-language.ts new file mode 100644 index 0000000..c0fa81b --- /dev/null +++ b/src/languages/bash-language.ts @@ -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 & { + 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, + ): ParseResult { + 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, + }); + } +} diff --git a/src/languages/bash-source-code.spec.ts b/src/languages/bash-source-code.spec.ts new file mode 100644 index 0000000..d741128 --- /dev/null +++ b/src/languages/bash-source-code.spec.ts @@ -0,0 +1,200 @@ +/** + * @fileoverview Unit tests for BashSourceCode. + */ + +import { describe, expect, it } from "vitest"; +import { parseBash } from "../parser/parse.js"; +import { BashSourceCode } from "./bash-source-code.js"; +import type { CommandNode, ProgramNode } from "../types.js"; + +function createSourceCode(text: string): BashSourceCode { + const { ast } = parseBash(text); + + return new BashSourceCode({ text, ast }); +} + +describe("BashSourceCode", () => { + describe("basics", () => { + it("should expose text, ast, and comments", () => { + const text = "# note\necho hi\n"; + const sourceCode = createSourceCode(text); + + expect(sourceCode.text).toBe(text); + expect(sourceCode.ast.type).toBe("Program"); + expect(sourceCode.comments).toHaveLength(1); + }); + + it("should expose lines", () => { + const sourceCode = createSourceCode("echo one\necho two\n"); + + expect(sourceCode.lines.slice(0, 2)).toEqual([ + "echo one", + "echo two", + ]); + }); + }); + + describe("getRange and getLoc", () => { + it("should compute ranges from start/end offsets", () => { + const text = "echo hi\n"; + const sourceCode = createSourceCode(text); + const command = sourceCode.ast.body[0] as CommandNode; + + expect(sourceCode.getRange(command)).toEqual([0, 7]); + }); + + it("should compute 1-based line and column locations", () => { + const text = "echo one\necho two\n"; + const sourceCode = createSourceCode(text); + const second = sourceCode.ast.body[1] as CommandNode; + const loc = sourceCode.getLoc(second); + + expect(loc.start).toEqual({ line: 2, column: 1 }); + expect(loc.end).toEqual({ line: 2, column: 9 }); + }); + + it("should locate nodes in the middle of a line", () => { + const text = "echo one two\n"; + const sourceCode = createSourceCode(text); + const command = sourceCode.ast.body[0] as CommandNode; + const secondArg = command.arguments[1]; + const loc = sourceCode.getLoc(secondArg!); + + expect(loc.start).toEqual({ line: 1, column: 10 }); + }); + }); + + describe("getText", () => { + it("should return the text of a node", () => { + const sourceCode = createSourceCode("echo one two\n"); + const command = sourceCode.ast.body[0] as CommandNode; + + expect(sourceCode.getText(command.arguments[0]!)).toBe("one"); + }); + }); + + describe("traverse", () => { + it("should yield enter and exit steps in order", () => { + const sourceCode = createSourceCode("echo hi\n"); + const steps = [...sourceCode.traverse()]; + const visits = steps.map(step => { + const target = step.target as { type: string }; + + return `${step.phase === 1 ? "enter" : "exit"}:${target.type}`; + }); + + expect(visits[0]).toBe("enter:Program"); + expect(visits.at(-1)).toBe("exit:Program"); + expect(visits).toContain("enter:Command"); + expect(visits).toContain("enter:Word"); + expect(visits).toContain("enter:Literal"); + }); + + it("should be repeatable", () => { + const sourceCode = createSourceCode("echo hi\n"); + const firstCount = [...sourceCode.traverse()].length; + const secondCount = [...sourceCode.traverse()].length; + + expect(firstCount).toBe(secondCount); + }); + }); + + describe("getParent and getAncestors", () => { + it("should return parents after traversal", () => { + const sourceCode = createSourceCode("echo hi\n"); + const command = sourceCode.ast.body[0] as CommandNode; + + expect(sourceCode.getParent(command)).toBe(sourceCode.ast); + expect(sourceCode.getParent(command.name!)).toBe(command); + expect(sourceCode.getParent(sourceCode.ast)).toBeUndefined(); + }); + + it("should return ancestors from root to parent", () => { + const sourceCode = createSourceCode("echo hi\n"); + const command = sourceCode.ast.body[0] as CommandNode; + const name = command.name!; + const ancestors = sourceCode.getAncestors(name); + + expect(ancestors[0]?.type).toBe("Program"); + expect(ancestors.at(-1)).toBe(command); + }); + }); + + describe("inline config", () => { + it("should find inline config comments", () => { + const sourceCode = createSourceCode( + [ + "# eslint-disable-next-line bash/no-backticks", + "echo `pwd`", + "# a normal comment", + "# eslint bash/no-useless-echo: 'off'", + "", + ].join("\n"), + ); + const nodes = sourceCode.getInlineConfigNodes(); + + expect(nodes).toHaveLength(2); + }); + + it("should produce disable directives", () => { + const sourceCode = createSourceCode( + [ + "# eslint-disable bash/no-backticks -- legacy file", + "echo `pwd`", + "# eslint-enable bash/no-backticks", + "# eslint-disable-line", + "# eslint-disable-next-line bash/no-unused-vars", + "", + ].join("\n"), + ); + const { directives, problems } = sourceCode.getDisableDirectives(); + + expect(problems).toHaveLength(0); + expect(directives.map(directive => directive.type)).toEqual([ + "disable", + "enable", + "disable-line", + "disable-next-line", + ]); + expect(directives[0]?.value).toBe("bash/no-backticks"); + expect(directives[0]?.justification).toBe("legacy file"); + }); + + it("should apply inline rule configuration", () => { + const sourceCode = createSourceCode( + '# eslint bash/no-backticks: "warn"\necho hi\n', + ); + const { configs, problems } = sourceCode.applyInlineConfig(); + + expect(problems).toHaveLength(0); + expect(configs).toHaveLength(1); + expect(configs[0]?.config.rules).toEqual({ + "bash/no-backticks": "warn", + }); + }); + + it("should report problems for malformed inline config", () => { + const sourceCode = createSourceCode( + "# eslint bash/no-backticks: oops(\necho hi\n", + ); + const { problems } = sourceCode.applyInlineConfig(); + + expect(problems.length).toBeGreaterThan(0); + }); + }); +}); + +describe("BashSourceCode construction", () => { + it("should accept a manually built program", () => { + const ast: ProgramNode = { + type: "Program", + start: 0, + end: 0, + body: [], + comments: [], + }; + const sourceCode = new BashSourceCode({ text: "", ast }); + + expect(sourceCode.ast).toBe(ast); + }); +}); diff --git a/src/languages/bash-source-code.ts b/src/languages/bash-source-code.ts new file mode 100644 index 0000000..12b6be0 --- /dev/null +++ b/src/languages/bash-source-code.ts @@ -0,0 +1,271 @@ +/** + * @fileoverview The BashSourceCode class, the SourceCode implementation that + * ESLint uses to interact with a parsed Bash file. + */ + +import { + ConfigCommentParser, + Directive, + TextSourceCodeBase, + VisitNodeStep, +} from "@eslint/plugin-kit"; +import type { + DirectiveType, + FileProblem, + RulesConfig, + SourceLocation, + SourceRange, + TraversalStep, +} from "@eslint/core"; +import { visitorKeys } from "../visitor-keys.js"; +import type { + BashLanguageOptions, + BashNode, + CommentNode, + ProgramNode, +} from "../types.js"; + +const commentParser = new ConfigCommentParser(); + +const INLINE_CONFIG = + /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u; + +/** + * The directive labels ESLint understands, mapped to directive types. + */ +const DIRECTIVE_TYPES = new Map([ + ["eslint-disable", "disable"], + ["eslint-enable", "enable"], + ["eslint-disable-line", "disable-line"], + ["eslint-disable-next-line", "disable-next-line"], +]); + +export interface BashSourceCodeOptions { + text: string; + ast: ProgramNode; +} + +/** + * SourceCode implementation for Bash files. Nodes carry `start`/`end` + * character offsets; `getLoc()` and `getRange()` derive positions from + * those offsets, so nodes have no `loc` or `range` properties. + */ +export class BashSourceCode extends TextSourceCodeBase<{ + LangOptions: BashLanguageOptions; + RootNode: ProgramNode; + SyntaxElementWithLoc: BashNode; + ConfigNode: CommentNode; +}> { + /** All comments found in the file, in source order. */ + comments: CommentNode[]; + + #parents = new Map(); + #steps: VisitNodeStep[] | null = null; + #lineOffsets: number[] | null = null; + #inlineConfigComments: CommentNode[] | null = null; + + constructor({ text, ast }: BashSourceCodeOptions) { + super({ text, ast }); + this.comments = ast.comments; + } + + /** Character offsets at which each line starts. */ + get #lineStarts(): number[] { + if (!this.#lineOffsets) { + const offsets = [0]; + + for (let i = 0; i < this.text.length; i++) { + if (this.text[i] === "\n") { + offsets.push(i + 1); + } + } + + this.#lineOffsets = offsets; + } + + return this.#lineOffsets; + } + + /** + * Converts a character offset into a 1-based line/column pair. + */ + #locFromIndex(index: number): { line: number; column: number } { + const lineStarts = this.#lineStarts; + let low = 0; + let high = lineStarts.length - 1; + + while (low < high) { + const mid = (low + high + 1) >> 1; + + if ((lineStarts[mid] as number) <= index) { + low = mid; + } else { + high = mid - 1; + } + } + + return { + line: low + 1, + column: index - (lineStarts[low] as number) + 1, + }; + } + + getLoc(node: BashNode): SourceLocation { + return { + start: this.#locFromIndex(node.start), + end: this.#locFromIndex(node.end), + }; + } + + getRange(node: BashNode): SourceRange { + return [node.start, node.end]; + } + + getParent(node: BashNode): BashNode | undefined { + this.#ensureTraversed(); + return this.#parents.get(node); + } + + #ensureTraversed(): void { + if (!this.#steps) { + void [...this.traverse()]; + } + } + + traverse(): Iterable { + if (this.#steps) { + return this.#steps.values(); + } + + const steps: VisitNodeStep[] = (this.#steps = []); + + const visit = (node: BashNode, parent: BashNode | undefined): void => { + if (parent) { + this.#parents.set(node, parent); + } + + steps.push( + new VisitNodeStep({ + target: node, + phase: 1, + args: [node, parent], + }), + ); + + for (const key of visitorKeys[node.type] ?? []) { + const child = (node as unknown as Record)[key]; + + if (Array.isArray(child)) { + for (const element of child) { + if (element) { + visit(element as BashNode, node); + } + } + } else if (child) { + visit(child as BashNode, node); + } + } + + steps.push( + new VisitNodeStep({ + target: node, + phase: 2, + args: [node, parent], + }), + ); + }; + + visit(this.ast, undefined); + + return steps.values(); + } + + /** + * Returns all comments that look like inline ESLint configuration. + */ + getInlineConfigNodes(): CommentNode[] { + if (!this.#inlineConfigComments) { + this.#inlineConfigComments = this.comments.filter(comment => + INLINE_CONFIG.test(comment.text), + ); + } + + return this.#inlineConfigComments; + } + + /** + * Returns directives for disabling/enabling rules found in comments, + * such as `# eslint-disable-next-line bash/no-backticks`. + */ + getDisableDirectives(): { + directives: Directive[]; + problems: FileProblem[]; + } { + const directives: Directive[] = []; + const problems: FileProblem[] = []; + + for (const comment of this.getInlineConfigNodes()) { + const parsed = commentParser.parseDirective(comment.text.trim()); + + if (!parsed) { + continue; + } + + const directiveType = DIRECTIVE_TYPES.get(parsed.label); + + if (!directiveType) { + continue; + } + + directives.push( + new Directive({ + type: directiveType, + node: comment, + value: parsed.value, + justification: parsed.justification, + }), + ); + } + + return { directives, problems }; + } + + /** + * Applies `# eslint rule: severity` configuration comments. + */ + applyInlineConfig(): { + configs: { config: { rules: RulesConfig }; loc: SourceLocation }[]; + problems: FileProblem[]; + } { + const configs: { + config: { rules: RulesConfig }; + loc: SourceLocation; + }[] = []; + const problems: FileProblem[] = []; + + for (const comment of this.getInlineConfigNodes()) { + const parsed = commentParser.parseDirective(comment.text.trim()); + + if (!parsed || parsed.label !== "eslint") { + continue; + } + + const parseResult = commentParser.parseJSONLikeConfig(parsed.value); + + if (parseResult.ok) { + configs.push({ + config: { rules: parseResult.config }, + loc: this.getLoc(comment), + }); + } else { + problems.push({ + ruleId: null, + message: parseResult.error.message, + loc: this.getLoc(comment), + }); + } + } + + return { configs, problems }; + } +} diff --git a/src/types.spec.ts b/src/types.spec.ts new file mode 100644 index 0000000..857cd76 --- /dev/null +++ b/src/types.spec.ts @@ -0,0 +1,46 @@ +/** + * @fileoverview Type-level tests for the syntax tree definitions. + */ + +import { describe, expectTypeOf, it } from "vitest"; +import type { + BashNode, + BashRuleDefinition, + CommandNode, + ProgramNode, + StatementNode, + WordPartNode, +} from "./types.js"; + +describe("types", () => { + it("should type Program nodes", () => { + const program: ProgramNode = { + type: "Program", + start: 0, + end: 0, + body: [], + comments: [], + }; + + expectTypeOf(program.type).toEqualTypeOf<"Program">(); + expectTypeOf(program.body).toEqualTypeOf(); + expectTypeOf(program).toMatchTypeOf(); + }); + + it("should type Command nodes as statements", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toBeArray(); + }); + + it("should discriminate word parts by type", () => { + const part = { type: "Literal", start: 0, end: 1, value: "x" } as const; + + expectTypeOf(part).toMatchTypeOf(); + }); + + it("should type rule definitions", () => { + expectTypeOf< + BashRuleDefinition<{ MessageIds: "oops" }> + >().toHaveProperty("create"); + }); +}); diff --git a/src/types.ts b/src/types.ts index d26dd9c..4674bde 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,16 @@ /** - * @fileoverview Type definitions for the Bash ESTree-style syntax tree. See - * docs/syntax-tree.md for the full documentation of the tree format. + * @fileoverview Type definitions for the Bash ESTree-style syntax tree and + * for rules operating on it. See docs/syntax-tree.md for the full + * documentation of the tree format. */ +import type { + CustomRuleDefinitionType, + CustomRuleTypeDefinitions, + CustomRuleVisitorWithExit, +} from "@eslint/plugin-kit"; +import type { BashSourceCode } from "./languages/bash-source-code.js"; + //------------------------------------------------------------------------------ // Base //------------------------------------------------------------------------------ @@ -436,3 +444,74 @@ export type BashNode = //------------------------------------------------------------------------------ export type BashShellVariant = "bash" | "posix" | "mksh"; + +export interface BashLanguageOptions { + /** The shell dialect to parse. Defaults to `"bash"`. */ + variant?: BashShellVariant; + + [key: string]: unknown; +} + +//------------------------------------------------------------------------------ +// Rules +//------------------------------------------------------------------------------ + +/** + * Visitor object for Bash rules. Keys are node types, optionally with an + * `:exit` suffix. + */ +export type BashRuleVisitor = CustomRuleVisitorWithExit<{ + Program?(node: ProgramNode): void; + Comment?(node: CommentNode): void; + Command?(node: CommandNode): void; + Pipeline?(node: PipelineNode): void; + LogicalExpression?(node: LogicalExpressionNode): void; + Subshell?(node: SubshellNode): void; + BlockStatement?(node: BlockStatementNode): void; + IfStatement?(node: IfStatementNode): void; + ElseClause?(node: ElseClauseNode): void; + WhileStatement?(node: WhileStatementNode): void; + UntilStatement?(node: UntilStatementNode): void; + ForStatement?(node: ForStatementNode): void; + ArithmeticForStatement?(node: ArithmeticForStatementNode): void; + CaseStatement?(node: CaseStatementNode): void; + CaseClause?(node: CaseClauseNode): void; + FunctionDeclaration?(node: FunctionDeclarationNode): void; + TestCommand?(node: TestCommandNode): void; + ArithmeticCommand?(node: ArithmeticCommandNode): void; + DeclarationCommand?(node: DeclarationCommandNode): void; + TimeCommand?(node: TimeCommandNode): void; + CoprocCommand?(node: CoprocCommandNode): void; + LetCommand?(node: LetCommandNode): void; + Word?(node: WordNode): void; + Literal?(node: LiteralNode): void; + SingleQuotedString?(node: SingleQuotedStringNode): void; + DoubleQuotedString?(node: DoubleQuotedStringNode): void; + ParameterExpansion?(node: ParameterExpansionNode): void; + CommandSubstitution?(node: CommandSubstitutionNode): void; + ProcessSubstitution?(node: ProcessSubstitutionNode): void; + ArithmeticExpansion?(node: ArithmeticExpansionNode): void; + ExtendedGlob?(node: ExtendedGlobNode): void; + VariableAssignment?(node: VariableAssignmentNode): void; + ArrayExpression?(node: ArrayExpressionNode): void; + ArrayElement?(node: ArrayElementNode): void; + Identifier?(node: IdentifierNode): void; + Redirect?(node: RedirectNode): void; + BinaryArithmetic?(node: BinaryArithmeticNode): void; + UnaryArithmetic?(node: UnaryArithmeticNode): void; + ParenthesizedArithmetic?(node: ParenthesizedArithmeticNode): void; + BinaryTest?(node: BinaryTestNode): void; + UnaryTest?(node: UnaryTestNode): void; + ParenthesizedTest?(node: ParenthesizedTestNode): void; +}>; + +export type BashRuleDefinitionTypeOptions = { + LangOptions: BashLanguageOptions; + Code: BashSourceCode; + Visitor: BashRuleVisitor; + Node: BashNode; +}; + +export type BashRuleDefinition< + Options extends Partial = object, +> = CustomRuleDefinitionType; diff --git a/src/visitor-keys.spec.ts b/src/visitor-keys.spec.ts new file mode 100644 index 0000000..3ef34df --- /dev/null +++ b/src/visitor-keys.spec.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview Unit tests for the visitor keys. + */ + +import { describe, expect, it } from "vitest"; +import { visitorKeys } from "./visitor-keys.js"; + +describe("visitorKeys", () => { + it("should be frozen", () => { + expect(Object.isFrozen(visitorKeys)).toBe(true); + }); + + it("should map every node type to an array of strings", () => { + for (const [type, keys] of Object.entries(visitorKeys)) { + expect(Array.isArray(keys), `keys for ${type}`).toBe(true); + + for (const key of keys) { + expect(typeof key).toBe("string"); + } + } + }); + + it("should contain all node types", () => { + const expected = [ + "Program", + "Comment", + "Command", + "Pipeline", + "LogicalExpression", + "Subshell", + "BlockStatement", + "IfStatement", + "ElseClause", + "WhileStatement", + "UntilStatement", + "ForStatement", + "ArithmeticForStatement", + "CaseStatement", + "CaseClause", + "FunctionDeclaration", + "TestCommand", + "ArithmeticCommand", + "DeclarationCommand", + "TimeCommand", + "CoprocCommand", + "LetCommand", + "Word", + "Literal", + "SingleQuotedString", + "DoubleQuotedString", + "ParameterExpansion", + "CommandSubstitution", + "ProcessSubstitution", + "ArithmeticExpansion", + "ExtendedGlob", + "VariableAssignment", + "ArrayExpression", + "ArrayElement", + "Identifier", + "Redirect", + "BinaryArithmetic", + "UnaryArithmetic", + "ParenthesizedArithmetic", + "BinaryTest", + "UnaryTest", + "ParenthesizedTest", + ]; + + for (const type of expected) { + expect(visitorKeys, `missing ${type}`).toHaveProperty(type); + } + }); + + it("should not traverse leaf nodes", () => { + expect(visitorKeys.Literal).toEqual([]); + expect(visitorKeys.Identifier).toEqual([]); + expect(visitorKeys.Comment).toEqual([]); + expect(visitorKeys.SingleQuotedString).toEqual([]); + }); +}); diff --git a/src/visitor-keys.ts b/src/visitor-keys.ts new file mode 100644 index 0000000..3584dfe --- /dev/null +++ b/src/visitor-keys.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Visitor keys describing the traversal order of the Bash + * syntax tree. Keys are listed in source order. + */ + +export const visitorKeys: Record = Object.freeze({ + Program: ["body"], + Comment: [], + + // Statements + Command: ["assignments", "name", "arguments", "redirects"], + Pipeline: ["commands", "redirects"], + LogicalExpression: ["left", "right", "redirects"], + Subshell: ["body", "redirects"], + BlockStatement: ["body", "redirects"], + IfStatement: ["test", "consequent", "alternate", "redirects"], + ElseClause: ["body"], + WhileStatement: ["test", "body", "redirects"], + UntilStatement: ["test", "body", "redirects"], + ForStatement: ["variable", "words", "body", "redirects"], + ArithmeticForStatement: ["init", "test", "update", "body", "redirects"], + CaseStatement: ["discriminant", "cases", "redirects"], + CaseClause: ["patterns", "body"], + FunctionDeclaration: ["id", "body", "redirects"], + TestCommand: ["expression", "redirects"], + ArithmeticCommand: ["expression", "redirects"], + DeclarationCommand: ["arguments", "redirects"], + TimeCommand: ["body", "redirects"], + CoprocCommand: ["name", "body", "redirects"], + LetCommand: ["expressions", "redirects"], + + // Words and word parts + Word: ["parts"], + Literal: [], + SingleQuotedString: [], + DoubleQuotedString: ["parts"], + ParameterExpansion: [ + "index", + "word", + "replacement", + "sliceOffset", + "sliceLength", + ], + CommandSubstitution: ["body"], + ProcessSubstitution: ["body"], + ArithmeticExpansion: ["expression"], + ExtendedGlob: [], + + // Assignments, identifiers, redirects + VariableAssignment: ["name", "index", "value", "array"], + ArrayExpression: ["elements"], + ArrayElement: ["index", "value"], + Identifier: [], + Redirect: ["target", "heredoc"], + + // Arithmetic expressions + BinaryArithmetic: ["left", "right"], + UnaryArithmetic: ["argument"], + ParenthesizedArithmetic: ["expression"], + + // Test expressions + BinaryTest: ["left", "right"], + UnaryTest: ["argument"], + ParenthesizedTest: ["expression"], +}); diff --git a/tests/directives.test.ts b/tests/directives.test.ts new file mode 100644 index 0000000..8a12a7b --- /dev/null +++ b/tests/directives.test.ts @@ -0,0 +1,116 @@ +/** + * @fileoverview Integration tests for inline configuration comments. + */ + +import { describe, expect, it } from "vitest"; +import { Linter } from "eslint"; +import bash from "../src/index.js"; +import testPlugin from "./fixtures/test-plugin.js"; + +function lint( + code: string, + rules: Record, + linterOptions: Record = {}, +): Linter.LintMessage[] { + const linter = new Linter(); + + return linter.verify( + code, + [ + { + files: ["**/*.sh"], + plugins: { bash, test: testPlugin }, + language: "bash/bash", + linterOptions, + rules: rules as never, + }, + ] as never, + "script.sh", + ); +} + +describe("disable directives", () => { + it("should honor eslint-disable-next-line", () => { + const messages = lint( + "# eslint-disable-next-line test/no-forbidden\nforbidden\n", + { "test/no-forbidden": "error" }, + ); + + expect(messages).toEqual([]); + }); + + it("should honor eslint-disable-line", () => { + const messages = lint( + "forbidden # eslint-disable-line test/no-forbidden\n", + { "test/no-forbidden": "error" }, + ); + + expect(messages).toEqual([]); + }); + + it("should honor eslint-disable/eslint-enable blocks", () => { + const messages = lint( + [ + "# eslint-disable test/no-forbidden", + "forbidden one", + "# eslint-enable test/no-forbidden", + "forbidden two", + "", + ].join("\n"), + { "test/no-forbidden": "error" }, + ); + + expect(messages).toHaveLength(1); + expect(messages[0]?.line).toBe(4); + }); + + it("should only disable the named rule", () => { + const messages = lint( + "# eslint-disable-next-line test/no-deprecated\nforbidden\n", + { "test/no-forbidden": "error", "test/no-deprecated": "error" }, + ); + + // The unused directive itself is also reported (ruleId null). + const ruleMessages = messages.filter( + message => message.ruleId !== null, + ); + + expect(ruleMessages).toHaveLength(1); + expect(ruleMessages[0]?.ruleId).toBe("test/no-forbidden"); + }); + + it("should report unused disable directives when asked", () => { + const messages = lint( + "# eslint-disable-next-line test/no-forbidden\necho ok\n", + { "test/no-forbidden": "error" }, + { reportUnusedDisableDirectives: "error" }, + ); + + expect(messages).toHaveLength(1); + expect(messages[0]?.message).toMatch(/Unused eslint-disable/u); + }); +}); + +describe("inline rule configuration", () => { + it("should apply severity from eslint comments", () => { + const messages = lint( + '# eslint test/no-forbidden: "warn"\nforbidden\n', + { + "test/no-forbidden": "error", + }, + ); + + expect(messages).toHaveLength(1); + expect(messages[0]?.severity).toBe(1); + }); + + it("should enable rules from eslint comments", () => { + const messages = lint( + '# eslint test/no-forbidden: "error"\nforbidden\n', + {}, + ); + + expect(messages).toHaveLength(1); + expect(messages[0]?.ruleId).toBe("test/no-forbidden"); + }); +}); diff --git a/tests/fixtures/test-plugin.ts b/tests/fixtures/test-plugin.ts new file mode 100644 index 0000000..ab68215 --- /dev/null +++ b/tests/fixtures/test-plugin.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview A test-only plugin with rules that exercise the Bash + * language independently of the rules shipped by the plugin. + */ + +import type { BashRuleDefinition, CommandNode } from "../../src/index.js"; + +/** + * Creates a rule that reports every simple command with the given name. + */ +function createCommandRule( + commandName: string, +): BashRuleDefinition<{ MessageIds: "found" }> { + return { + meta: { + type: "problem", + schema: [], + messages: { + found: `Unexpected '${commandName}' command.`, + }, + }, + + create(context) { + return { + Command(node: CommandNode) { + const [part] = node.name?.parts ?? []; + + if ( + part?.type === "Literal" && + part.value === commandName + ) { + context.report({ node, messageId: "found" }); + } + }, + }; + }, + }; +} + +export default { + rules: { + "no-forbidden": createCommandRule("forbidden"), + "no-deprecated": createCommandRule("deprecated"), + }, +}; diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..28fe881 --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,138 @@ +/** + * @fileoverview Integration tests running the plugin through the ESLint + * Linter API. + */ + +import { describe, expect, it } from "vitest"; +import { Linter } from "eslint"; +import bash from "../src/index.js"; +import testPlugin from "./fixtures/test-plugin.js"; + +function lint( + code: string, + rules: Record = {}, + languageOptions?: Record, +): Linter.LintMessage[] { + const linter = new Linter(); + + return linter.verify( + code, + [ + { + files: ["**/*.sh"], + plugins: { bash, test: testPlugin }, + language: "bash/bash", + rules: rules as never, + ...(languageOptions ? { languageOptions } : {}), + }, + ] as never, + "script.sh", + ); +} + +describe("language integration", () => { + it("should lint a clean script without messages", () => { + const messages = lint( + ["#!/bin/bash", 'greeting="hello"', 'echo "$greeting"', ""].join( + "\n", + ), + { "test/no-forbidden": "error" }, + ); + + expect(messages).toEqual([]); + }); + + it("should report rule violations with correct positions", () => { + const messages = lint("echo ok\n forbidden now\n", { + "test/no-forbidden": "error", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + ruleId: "test/no-forbidden", + line: 2, + column: 3, + endLine: 2, + endColumn: 16, + severity: 2, + }); + }); + + it("should report correct positions after non-ASCII text", () => { + const messages = lint('echo "héllo 🎉"; forbidden\n', { + "test/no-forbidden": "error", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + line: 1, + column: 18, + endColumn: 27, + }); + }); + + it("should visit nested statements", () => { + const messages = lint("if true; then\n x=$(forbidden)\nfi\n", { + "test/no-forbidden": "error", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]?.line).toBe(2); + }); + + it("should report syntax errors as fatal messages", () => { + const messages = lint("if then fi\n"); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + fatal: true, + line: 1, + }); + }); + + it("should respect the variant language option", () => { + const code = "diff <(sort a) <(sort b)\n"; + + expect(lint(code)).toEqual([]); + + const posixMessages = lint(code, {}, { variant: "posix" }); + + expect(posixMessages).toHaveLength(1); + expect(posixMessages[0]?.fatal).toBe(true); + }); +}); + +describe("recommended configuration", () => { + it("should parse shell files as Bash", () => { + const linter = new Linter(); + const messages = linter.verify( + "if then fi\n", + [bash.configs.recommended] as never, + "script.sh", + ); + + expect(messages).toHaveLength(1); + expect(messages[0]?.fatal).toBe(true); + }); + + it("should also apply to .bash files", () => { + const linter = new Linter(); + const messages = linter.verify( + "if then fi\n", + [bash.configs.recommended] as never, + "script.bash", + ); + + expect(messages[0]?.fatal).toBe(true); + }); + + it("should only apply to shell files", () => { + // Valid JavaScript but invalid Bash, so only a Bash parse fails. + const code = "if (ready) {}\n"; + const linter = new Linter(); + const config = [bash.configs.recommended] as never; + + expect(linter.verify(code, config, "script.sh")[0]?.fatal).toBe(true); + expect(linter.verify(code, config, "script.js")).toEqual([]); + }); +}); From 080d81a782cbad4cabdb9f6d530462733eb9ec0f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 1 Oct 2026 13:21:05 -0400 Subject: [PATCH 2/2] fix: remove unreachable Comment handler from rule visitor type Comments are exposed through Program.comments and are never traversed, so a Comment handler could be declared but would never run. Co-Authored-By: Claude Opus 5.5 --- src/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 4674bde..ae39f97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -462,7 +462,6 @@ export interface BashLanguageOptions { */ export type BashRuleVisitor = CustomRuleVisitorWithExit<{ Program?(node: ProgramNode): void; - Comment?(node: CommentNode): void; Command?(node: CommandNode): void; Pipeline?(node: PipelineNode): void; LogicalExpression?(node: LogicalExpressionNode): void;