From 4f6f3b295a98d7da8c158cf02cc0e9de7864c9e9 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 10:35:27 -0400 Subject: [PATCH 1/5] 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 5ab68d0..8a55b6e 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 786f2fa..aa560cb 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -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", () => { @@ -16,4 +17,37 @@ describe("plugin", () => { expect(parseShell("echo hi\n").ast.type).toBe("Program"); expect(() => parseShell("if then fi\n")).toThrow(ShellSyntaxError); }); + + it("should expose the bash language", () => { + expect(plugin.languages.bash).toBeInstanceOf(ShellLanguage); + }); + + 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); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 6e55f77..2298ef6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,14 +3,37 @@ * 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(), + }, + 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"; 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 887c927..d4f4872 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,16 @@ /** - * @fileoverview Type definitions for the shell ESTree-style syntax tree. See - * docs/syntax-tree.md for the full documentation of the tree format. + * @fileoverview Type definitions for the shell 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 { ShellSourceCode } from "./languages/shell-source-code.js"; + //------------------------------------------------------------------------------ // Base //------------------------------------------------------------------------------ @@ -436,3 +444,74 @@ export type ShellNode = //------------------------------------------------------------------------------ export type ShellVariant = "bash" | "posix" | "mksh"; + +export interface ShellLanguageOptions { + /** The shell dialect to parse. Defaults to `"bash"`. */ + variant?: ShellVariant; + + [key: string]: unknown; +} + +//------------------------------------------------------------------------------ +// Rules +//------------------------------------------------------------------------------ + +/** + * Visitor object for shell rules. Keys are node types, optionally with an + * `:exit` suffix. + */ +export type ShellRuleVisitor = 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 ShellRuleDefinitionTypeOptions = { + LangOptions: ShellLanguageOptions; + Code: ShellSourceCode; + Visitor: ShellRuleVisitor; + Node: ShellNode; +}; + +export type ShellRuleDefinition< + 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 28b81a8a3319d74329aac115191ab9f25cc12d5a Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 1 Oct 2026 13:21:05 -0400 Subject: [PATCH 2/5] 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 d4f4872..5d62957 100644 --- a/src/types.ts +++ b/src/types.ts @@ -462,7 +462,6 @@ export interface ShellLanguageOptions { */ export type ShellRuleVisitor = CustomRuleVisitorWithExit<{ Program?(node: ProgramNode): void; - Comment?(node: CommentNode): void; Command?(node: CommandNode): void; Pipeline?(node: PipelineNode): void; LogicalExpression?(node: LogicalExpressionNode): void; From 476d0db638f3ecc5f694b2458c27bd7c97cfa62e Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:12:16 -0400 Subject: [PATCH 3/5] fix: rename Bash language classes to Shell Renames BashLanguage, BashSourceCode, and the language and rule types to their Shell equivalents, registers the plugin under the shell namespace in the recommended config, and updates the README to use @eslint/shell. Co-Authored-By: Claude Opus 5.5 --- ...anguage.spec.ts => shell-language.spec.ts} | 14 ++++---- .../{bash-language.ts => shell-language.ts} | 30 ++++++++--------- ...code.spec.ts => shell-source-code.spec.ts} | 32 +++++++++---------- ...sh-source-code.ts => shell-source-code.ts} | 18 +++++------ src/types.spec.ts | 4 +-- tests/directives.test.ts | 6 ++-- tests/fixtures/test-plugin.ts | 4 +-- tests/plugin.test.ts | 12 +++---- 8 files changed, 60 insertions(+), 60 deletions(-) rename src/languages/{bash-language.spec.ts => shell-language.spec.ts} (89%) rename src/languages/{bash-language.ts => shell-language.ts} (71%) rename src/languages/{bash-source-code.spec.ts => shell-source-code.spec.ts} (86%) rename src/languages/{bash-source-code.ts => shell-source-code.ts} (91%) diff --git a/src/languages/bash-language.spec.ts b/src/languages/shell-language.spec.ts similarity index 89% rename from src/languages/bash-language.spec.ts rename to src/languages/shell-language.spec.ts index a6c06de..503dde5 100644 --- a/src/languages/bash-language.spec.ts +++ b/src/languages/shell-language.spec.ts @@ -1,10 +1,10 @@ /** - * @fileoverview Unit tests for BashLanguage. + * @fileoverview Unit tests for ShellLanguage. */ import { describe, expect, it } from "vitest"; -import { BashLanguage } from "./bash-language.js"; -import { BashSourceCode } from "./bash-source-code.js"; +import { ShellLanguage } from "./shell-language.js"; +import { ShellSourceCode } from "./shell-source-code.js"; import type { File } from "@eslint/core"; function createFile(body: string): File { @@ -16,8 +16,8 @@ function createFile(body: string): File { }; } -describe("BashLanguage", () => { - const language = new BashLanguage(); +describe("ShellLanguage", () => { + const language = new ShellLanguage(); describe("metadata", () => { it("should describe itself as a text language", () => { @@ -98,7 +98,7 @@ describe("BashLanguage", () => { }); describe("createSourceCode", () => { - it("should create a BashSourceCode", () => { + it("should create a ShellSourceCode", () => { const file = createFile("echo hi\n"); const result = language.parse(file); @@ -110,7 +110,7 @@ describe("BashLanguage", () => { comments: [], }); - expect(sourceCode).toBeInstanceOf(BashSourceCode); + expect(sourceCode).toBeInstanceOf(ShellSourceCode); expect(sourceCode.text).toBe("echo hi\n"); } }); diff --git a/src/languages/bash-language.ts b/src/languages/shell-language.ts similarity index 71% rename from src/languages/bash-language.ts rename to src/languages/shell-language.ts index c0fa81b..481a721 100644 --- a/src/languages/bash-language.ts +++ b/src/languages/shell-language.ts @@ -1,6 +1,6 @@ /** - * @fileoverview The BashLanguage class, the ESLint Language implementation - * for Bash files. + * @fileoverview The ShellLanguage class, the ESLint Language implementation + * for shell scripts. */ import type { @@ -11,10 +11,10 @@ import type { ParseResult, } from "@eslint/core"; import { BashSyntaxError, parseBash } from "../parser/parse.js"; -import { BashSourceCode } from "./bash-source-code.js"; +import { ShellSourceCode } from "./shell-source-code.js"; import { visitorKeys } from "../visitor-keys.js"; import type { - BashLanguageOptions, + ShellLanguageOptions, BashNode, CommentNode, ProgramNode, @@ -22,16 +22,16 @@ import type { const SHELL_VARIANTS = new Set(["bash", "posix", "mksh"]); -export type BashOkParseResult = OkParseResult & { +export type ShellOkParseResult = OkParseResult & { comments: CommentNode[]; }; /** - * ESLint Language implementation for Bash. + * ESLint Language implementation for shell scripts. */ -export class BashLanguage implements Language<{ - LangOptions: BashLanguageOptions; - Code: BashSourceCode; +export class ShellLanguage implements Language<{ + LangOptions: ShellLanguageOptions; + Code: ShellSourceCode; RootNode: ProgramNode; Node: BashNode; }> { @@ -41,11 +41,11 @@ export class BashLanguage implements Language<{ nodeTypeKey = "type"; visitorKeys = visitorKeys; - defaultLanguageOptions: BashLanguageOptions = { + defaultLanguageOptions: ShellLanguageOptions = { variant: "bash", }; - validateLanguageOptions(languageOptions: BashLanguageOptions): void { + validateLanguageOptions(languageOptions: ShellLanguageOptions): void { if ( languageOptions.variant !== undefined && !SHELL_VARIANTS.has(languageOptions.variant as string) @@ -58,7 +58,7 @@ export class BashLanguage implements Language<{ parse( file: File, - context?: LanguageContext, + context?: LanguageContext, ): ParseResult { const text = file.body as string; @@ -101,9 +101,9 @@ export class BashLanguage implements Language<{ createSourceCode( file: File, - parseResult: BashOkParseResult, - ): BashSourceCode { - return new BashSourceCode({ + parseResult: ShellOkParseResult, + ): ShellSourceCode { + return new ShellSourceCode({ text: file.body as string, ast: parseResult.ast, }); diff --git a/src/languages/bash-source-code.spec.ts b/src/languages/shell-source-code.spec.ts similarity index 86% rename from src/languages/bash-source-code.spec.ts rename to src/languages/shell-source-code.spec.ts index d741128..dce0dff 100644 --- a/src/languages/bash-source-code.spec.ts +++ b/src/languages/shell-source-code.spec.ts @@ -1,19 +1,19 @@ /** - * @fileoverview Unit tests for BashSourceCode. + * @fileoverview Unit tests for ShellSourceCode. */ import { describe, expect, it } from "vitest"; import { parseBash } from "../parser/parse.js"; -import { BashSourceCode } from "./bash-source-code.js"; +import { ShellSourceCode } from "./shell-source-code.js"; import type { CommandNode, ProgramNode } from "../types.js"; -function createSourceCode(text: string): BashSourceCode { +function createSourceCode(text: string): ShellSourceCode { const { ast } = parseBash(text); - return new BashSourceCode({ text, ast }); + return new ShellSourceCode({ text, ast }); } -describe("BashSourceCode", () => { +describe("ShellSourceCode", () => { describe("basics", () => { it("should expose text, ast, and comments", () => { const text = "# note\necho hi\n"; @@ -124,10 +124,10 @@ describe("BashSourceCode", () => { it("should find inline config comments", () => { const sourceCode = createSourceCode( [ - "# eslint-disable-next-line bash/no-backticks", + "# eslint-disable-next-line shell/no-backticks", "echo `pwd`", "# a normal comment", - "# eslint bash/no-useless-echo: 'off'", + "# eslint shell/no-useless-echo: 'off'", "", ].join("\n"), ); @@ -139,11 +139,11 @@ describe("BashSourceCode", () => { it("should produce disable directives", () => { const sourceCode = createSourceCode( [ - "# eslint-disable bash/no-backticks -- legacy file", + "# eslint-disable shell/no-backticks -- legacy file", "echo `pwd`", - "# eslint-enable bash/no-backticks", + "# eslint-enable shell/no-backticks", "# eslint-disable-line", - "# eslint-disable-next-line bash/no-unused-vars", + "# eslint-disable-next-line shell/no-unused-vars", "", ].join("\n"), ); @@ -156,26 +156,26 @@ describe("BashSourceCode", () => { "disable-line", "disable-next-line", ]); - expect(directives[0]?.value).toBe("bash/no-backticks"); + expect(directives[0]?.value).toBe("shell/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', + '# eslint shell/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", + "shell/no-backticks": "warn", }); }); it("should report problems for malformed inline config", () => { const sourceCode = createSourceCode( - "# eslint bash/no-backticks: oops(\necho hi\n", + "# eslint shell/no-backticks: oops(\necho hi\n", ); const { problems } = sourceCode.applyInlineConfig(); @@ -184,7 +184,7 @@ describe("BashSourceCode", () => { }); }); -describe("BashSourceCode construction", () => { +describe("ShellSourceCode construction", () => { it("should accept a manually built program", () => { const ast: ProgramNode = { type: "Program", @@ -193,7 +193,7 @@ describe("BashSourceCode construction", () => { body: [], comments: [], }; - const sourceCode = new BashSourceCode({ text: "", ast }); + const sourceCode = new ShellSourceCode({ text: "", ast }); expect(sourceCode.ast).toBe(ast); }); diff --git a/src/languages/bash-source-code.ts b/src/languages/shell-source-code.ts similarity index 91% rename from src/languages/bash-source-code.ts rename to src/languages/shell-source-code.ts index 12b6be0..4f41701 100644 --- a/src/languages/bash-source-code.ts +++ b/src/languages/shell-source-code.ts @@ -1,6 +1,6 @@ /** - * @fileoverview The BashSourceCode class, the SourceCode implementation that - * ESLint uses to interact with a parsed Bash file. + * @fileoverview The ShellSourceCode class, the SourceCode implementation that + * ESLint uses to interact with a parsed shell script. */ import { @@ -19,7 +19,7 @@ import type { } from "@eslint/core"; import { visitorKeys } from "../visitor-keys.js"; import type { - BashLanguageOptions, + ShellLanguageOptions, BashNode, CommentNode, ProgramNode, @@ -40,18 +40,18 @@ const DIRECTIVE_TYPES = new Map([ ["eslint-disable-next-line", "disable-next-line"], ]); -export interface BashSourceCodeOptions { +export interface ShellSourceCodeOptions { text: string; ast: ProgramNode; } /** - * SourceCode implementation for Bash files. Nodes carry `start`/`end` + * SourceCode implementation for shell scripts. 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; +export class ShellSourceCode extends TextSourceCodeBase<{ + LangOptions: ShellLanguageOptions; RootNode: ProgramNode; SyntaxElementWithLoc: BashNode; ConfigNode: CommentNode; @@ -64,7 +64,7 @@ export class BashSourceCode extends TextSourceCodeBase<{ #lineOffsets: number[] | null = null; #inlineConfigComments: CommentNode[] | null = null; - constructor({ text, ast }: BashSourceCodeOptions) { + constructor({ text, ast }: ShellSourceCodeOptions) { super({ text, ast }); this.comments = ast.comments; } @@ -195,7 +195,7 @@ export class BashSourceCode extends TextSourceCodeBase<{ /** * Returns directives for disabling/enabling rules found in comments, - * such as `# eslint-disable-next-line bash/no-backticks`. + * such as `# eslint-disable-next-line shell/no-backticks`. */ getDisableDirectives(): { directives: Directive[]; diff --git a/src/types.spec.ts b/src/types.spec.ts index 857cd76..cba99eb 100644 --- a/src/types.spec.ts +++ b/src/types.spec.ts @@ -5,7 +5,7 @@ import { describe, expectTypeOf, it } from "vitest"; import type { BashNode, - BashRuleDefinition, + ShellRuleDefinition, CommandNode, ProgramNode, StatementNode, @@ -40,7 +40,7 @@ describe("types", () => { it("should type rule definitions", () => { expectTypeOf< - BashRuleDefinition<{ MessageIds: "oops" }> + ShellRuleDefinition<{ MessageIds: "oops" }> >().toHaveProperty("create"); }); }); diff --git a/tests/directives.test.ts b/tests/directives.test.ts index 8a12a7b..b5bb549 100644 --- a/tests/directives.test.ts +++ b/tests/directives.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { Linter } from "eslint"; -import bash from "../src/index.js"; +import shell from "../src/index.js"; import testPlugin from "./fixtures/test-plugin.js"; function lint( @@ -19,8 +19,8 @@ function lint( [ { files: ["**/*.sh"], - plugins: { bash, test: testPlugin }, - language: "bash/bash", + plugins: { shell, test: testPlugin }, + language: "shell/bash", linterOptions, rules: rules as never, }, diff --git a/tests/fixtures/test-plugin.ts b/tests/fixtures/test-plugin.ts index ab68215..0176bbb 100644 --- a/tests/fixtures/test-plugin.ts +++ b/tests/fixtures/test-plugin.ts @@ -3,14 +3,14 @@ * language independently of the rules shipped by the plugin. */ -import type { BashRuleDefinition, CommandNode } from "../../src/index.js"; +import type { ShellRuleDefinition, CommandNode } from "../../src/index.js"; /** * Creates a rule that reports every simple command with the given name. */ function createCommandRule( commandName: string, -): BashRuleDefinition<{ MessageIds: "found" }> { +): ShellRuleDefinition<{ MessageIds: "found" }> { return { meta: { type: "problem", diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 28fe881..69b13de 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { Linter } from "eslint"; -import bash from "../src/index.js"; +import shell from "../src/index.js"; import testPlugin from "./fixtures/test-plugin.js"; function lint( @@ -20,8 +20,8 @@ function lint( [ { files: ["**/*.sh"], - plugins: { bash, test: testPlugin }, - language: "bash/bash", + plugins: { shell, test: testPlugin }, + language: "shell/bash", rules: rules as never, ...(languageOptions ? { languageOptions } : {}), }, @@ -107,7 +107,7 @@ describe("recommended configuration", () => { const linter = new Linter(); const messages = linter.verify( "if then fi\n", - [bash.configs.recommended] as never, + [shell.configs.recommended] as never, "script.sh", ); @@ -119,7 +119,7 @@ describe("recommended configuration", () => { const linter = new Linter(); const messages = linter.verify( "if then fi\n", - [bash.configs.recommended] as never, + [shell.configs.recommended] as never, "script.bash", ); @@ -130,7 +130,7 @@ describe("recommended configuration", () => { // 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; + const config = [shell.configs.recommended] as never; expect(linter.verify(code, config, "script.sh")[0]?.fatal).toBe(true); expect(linter.verify(code, config, "script.js")).toEqual([]); From b8195455ecdead769ce1d99c7e0a462aae69fb68 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:39:07 -0400 Subject: [PATCH 4/5] feat: register a language for each shell variant Adds a variant constructor option to ShellLanguage and registers shell/bash, shell/posix, and shell/mksh. Each language uses its variant as the default for the variant language option. Co-Authored-By: Claude Opus 5.5 --- README.md | 41 ++++++++++++++++-------- src/index.spec.ts | 13 ++++++-- src/index.ts | 4 ++- src/languages/shell-language.spec.ts | 42 +++++++++++++++++++++++++ src/languages/shell-language.ts | 34 ++++++++++++++------ src/languages/shell-source-code.spec.ts | 4 +-- src/languages/shell-source-code.ts | 21 +++++++------ src/types.spec.ts | 4 +-- src/types.ts | 2 +- src/visitor-keys.ts | 2 +- tests/fixtures/test-plugin.ts | 2 +- tests/plugin.test.ts | 24 +++++++++++++- 12 files changed, 151 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 8a55b6e..9993376 100644 --- a/README.md +++ b/README.md @@ -22,42 +22,57 @@ Requires Node.js `^20.19.0 || ^22.13.0 || >=24`. Tested with ESLint v10. Add the plugin to your `eslint.config.js`: ```js -import bash from "@eslint/bash"; +import shell from "@eslint/shell"; export default [ // use the recommended rules for *.sh and *.bash files - bash.configs.recommended, + shell.configs.recommended, ]; ``` -### Language options +### Languages + +The plugin provides one language per shell dialect: -| Option | Values | Default | Description | -| --------- | ----------------------------- | -------- | -------------------------- | -| `variant` | `"bash"`, `"posix"`, `"mksh"` | `"bash"` | The shell dialect to parse | +| 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: { bash }, - language: "bash/bash", - languageOptions: { variant: "posix" }, + 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 Bash files: +Standard ESLint configuration comments work inside shell scripts: ```bash -# eslint-disable-next-line bash/no-backticks +# eslint-disable-next-line shell/no-backticks echo `pwd` -echo `pwd` # eslint-disable-line bash/no-backticks -- legacy +echo `pwd` # eslint-disable-line shell/no-backticks -- legacy -# eslint bash/no-useless-echo: "warn" +# eslint shell/no-useless-echo: "warn" ``` ## Syntax tree diff --git a/src/index.spec.ts b/src/index.spec.ts index aa560cb..64f400c 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -18,8 +18,17 @@ describe("plugin", () => { expect(() => parseShell("if then fi\n")).toThrow(ShellSyntaxError); }); - it("should expose the bash language", () => { - expect(plugin.languages.bash).toBeInstanceOf(ShellLanguage); + 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", () => { diff --git a/src/index.ts b/src/index.ts index 2298ef6..68399eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,9 @@ const plugin = { version: "0.0.0", // x-release-please-version }, languages: { - bash: new ShellLanguage(), + bash: new ShellLanguage({ variant: "bash" }), + posix: new ShellLanguage({ variant: "posix" }), + mksh: new ShellLanguage({ variant: "mksh" }), }, rules, configs: { diff --git a/src/languages/shell-language.spec.ts b/src/languages/shell-language.spec.ts index 503dde5..a059eed 100644 --- a/src/languages/shell-language.spec.ts +++ b/src/languages/shell-language.spec.ts @@ -32,6 +32,27 @@ describe("ShellLanguage", () => { }); }); + 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(() => @@ -80,6 +101,27 @@ describe("ShellLanguage", () => { } }); + 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"), diff --git a/src/languages/shell-language.ts b/src/languages/shell-language.ts index 481a721..bda1271 100644 --- a/src/languages/shell-language.ts +++ b/src/languages/shell-language.ts @@ -10,12 +10,13 @@ import type { OkParseResult, ParseResult, } from "@eslint/core"; -import { BashSyntaxError, parseBash } from "../parser/parse.js"; +import { ShellSyntaxError, parseShell } from "../parser/parse.js"; import { ShellSourceCode } from "./shell-source-code.js"; import { visitorKeys } from "../visitor-keys.js"; import type { ShellLanguageOptions, - BashNode, + ShellNode, + ShellVariant, CommentNode, ProgramNode, } from "../types.js"; @@ -26,6 +27,11 @@ export type ShellOkParseResult = OkParseResult & { comments: CommentNode[]; }; +export interface ShellLanguageConstructorOptions { + /** The shell dialect this language parses. Defaults to `"bash"`. */ + variant?: ShellVariant; +} + /** * ESLint Language implementation for shell scripts. */ @@ -33,7 +39,7 @@ export class ShellLanguage implements Language<{ LangOptions: ShellLanguageOptions; Code: ShellSourceCode; RootNode: ProgramNode; - Node: BashNode; + Node: ShellNode; }> { fileType = "text" as const; lineStart = 1 as const; @@ -41,9 +47,17 @@ export class ShellLanguage implements Language<{ nodeTypeKey = "type"; visitorKeys = visitorKeys; - defaultLanguageOptions: ShellLanguageOptions = { - variant: "bash", - }; + defaultLanguageOptions: ShellLanguageOptions; + + constructor({ variant = "bash" }: ShellLanguageConstructorOptions = {}) { + if (!SHELL_VARIANTS.has(variant)) { + throw new TypeError( + `Invalid shell variant "${String(variant)}". Expected "bash", "posix", or "mksh".`, + ); + } + + this.defaultLanguageOptions = { variant }; + } validateLanguageOptions(languageOptions: ShellLanguageOptions): void { if ( @@ -63,14 +77,16 @@ export class ShellLanguage implements Language<{ const text = file.body as string; try { - const { ast, comments } = parseBash(text, { - variant: context?.languageOptions?.variant, + const { ast, comments } = parseShell(text, { + variant: + context?.languageOptions?.variant ?? + this.defaultLanguageOptions.variant, path: file.path, }); return { ok: true, ast, comments }; } catch (error) { - if (error instanceof BashSyntaxError) { + if (error instanceof ShellSyntaxError) { return { ok: false, errors: [ diff --git a/src/languages/shell-source-code.spec.ts b/src/languages/shell-source-code.spec.ts index dce0dff..3268ef5 100644 --- a/src/languages/shell-source-code.spec.ts +++ b/src/languages/shell-source-code.spec.ts @@ -3,12 +3,12 @@ */ import { describe, expect, it } from "vitest"; -import { parseBash } from "../parser/parse.js"; +import { parseShell } from "../parser/parse.js"; import { ShellSourceCode } from "./shell-source-code.js"; import type { CommandNode, ProgramNode } from "../types.js"; function createSourceCode(text: string): ShellSourceCode { - const { ast } = parseBash(text); + const { ast } = parseShell(text); return new ShellSourceCode({ text, ast }); } diff --git a/src/languages/shell-source-code.ts b/src/languages/shell-source-code.ts index 4f41701..00efcb0 100644 --- a/src/languages/shell-source-code.ts +++ b/src/languages/shell-source-code.ts @@ -20,7 +20,7 @@ import type { import { visitorKeys } from "../visitor-keys.js"; import type { ShellLanguageOptions, - BashNode, + ShellNode, CommentNode, ProgramNode, } from "../types.js"; @@ -53,13 +53,13 @@ export interface ShellSourceCodeOptions { export class ShellSourceCode extends TextSourceCodeBase<{ LangOptions: ShellLanguageOptions; RootNode: ProgramNode; - SyntaxElementWithLoc: BashNode; + SyntaxElementWithLoc: ShellNode; ConfigNode: CommentNode; }> { /** All comments found in the file, in source order. */ comments: CommentNode[]; - #parents = new Map(); + #parents = new Map(); #steps: VisitNodeStep[] | null = null; #lineOffsets: number[] | null = null; #inlineConfigComments: CommentNode[] | null = null; @@ -110,18 +110,18 @@ export class ShellSourceCode extends TextSourceCodeBase<{ }; } - getLoc(node: BashNode): SourceLocation { + getLoc(node: ShellNode): SourceLocation { return { start: this.#locFromIndex(node.start), end: this.#locFromIndex(node.end), }; } - getRange(node: BashNode): SourceRange { + getRange(node: ShellNode): SourceRange { return [node.start, node.end]; } - getParent(node: BashNode): BashNode | undefined { + getParent(node: ShellNode): ShellNode | undefined { this.#ensureTraversed(); return this.#parents.get(node); } @@ -139,7 +139,10 @@ export class ShellSourceCode extends TextSourceCodeBase<{ const steps: VisitNodeStep[] = (this.#steps = []); - const visit = (node: BashNode, parent: BashNode | undefined): void => { + const visit = ( + node: ShellNode, + parent: ShellNode | undefined, + ): void => { if (parent) { this.#parents.set(node, parent); } @@ -158,11 +161,11 @@ export class ShellSourceCode extends TextSourceCodeBase<{ if (Array.isArray(child)) { for (const element of child) { if (element) { - visit(element as BashNode, node); + visit(element as ShellNode, node); } } } else if (child) { - visit(child as BashNode, node); + visit(child as ShellNode, node); } } diff --git a/src/types.spec.ts b/src/types.spec.ts index cba99eb..01f586b 100644 --- a/src/types.spec.ts +++ b/src/types.spec.ts @@ -4,7 +4,7 @@ import { describe, expectTypeOf, it } from "vitest"; import type { - BashNode, + ShellNode, ShellRuleDefinition, CommandNode, ProgramNode, @@ -24,7 +24,7 @@ describe("types", () => { expectTypeOf(program.type).toEqualTypeOf<"Program">(); expectTypeOf(program.body).toEqualTypeOf(); - expectTypeOf(program).toMatchTypeOf(); + expectTypeOf(program).toMatchTypeOf(); }); it("should type Command nodes as statements", () => { diff --git a/src/types.ts b/src/types.ts index 5d62957..7ddac83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -446,7 +446,7 @@ export type ShellNode = export type ShellVariant = "bash" | "posix" | "mksh"; export interface ShellLanguageOptions { - /** The shell dialect to parse. Defaults to `"bash"`. */ + /** The shell dialect to parse. Defaults to the variant of the language in use. */ variant?: ShellVariant; [key: string]: unknown; diff --git a/src/visitor-keys.ts b/src/visitor-keys.ts index 3584dfe..45a8a4d 100644 --- a/src/visitor-keys.ts +++ b/src/visitor-keys.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Visitor keys describing the traversal order of the Bash + * @fileoverview Visitor keys describing the traversal order of the shell * syntax tree. Keys are listed in source order. */ diff --git a/tests/fixtures/test-plugin.ts b/tests/fixtures/test-plugin.ts index 0176bbb..5730626 100644 --- a/tests/fixtures/test-plugin.ts +++ b/tests/fixtures/test-plugin.ts @@ -1,5 +1,5 @@ /** - * @fileoverview A test-only plugin with rules that exercise the Bash + * @fileoverview A test-only plugin with rules that exercise the shell * language independently of the rules shipped by the plugin. */ diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 69b13de..2060bff 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -12,6 +12,7 @@ function lint( code: string, rules: Record = {}, languageOptions?: Record, + language = "shell/bash", ): Linter.LintMessage[] { const linter = new Linter(); @@ -21,7 +22,7 @@ function lint( { files: ["**/*.sh"], plugins: { shell, test: testPlugin }, - language: "shell/bash", + language, rules: rules as never, ...(languageOptions ? { languageOptions } : {}), }, @@ -102,6 +103,27 @@ describe("language integration", () => { }); }); +describe("languages", () => { + it("should parse Bash syntax with the shell/bash language", () => { + const code = "diff <(sort a) <(sort b)\n"; + + expect(lint(code, {}, undefined, "shell/bash")).toEqual([]); + expect(lint(code, {}, undefined, "shell/posix")[0]?.fatal).toBe(true); + }); + + it("should parse POSIX sh syntax with the shell/posix language", () => { + expect(lint("echo hi\n", {}, undefined, "shell/posix")).toEqual([]); + }); + + it("should parse mksh syntax with the shell/mksh language", () => { + // `|&` with no following command starts a coprocess in mksh only. + const code = "cat |&\n"; + + expect(lint(code, {}, undefined, "shell/mksh")).toEqual([]); + expect(lint(code, {}, undefined, "shell/bash")[0]?.fatal).toBe(true); + }); +}); + describe("recommended configuration", () => { it("should parse shell files as Bash", () => { const linter = new Linter(); From 5e8ca08c0949814df52efbf95651ce9b50f4660f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 16:48:54 -0400 Subject: [PATCH 5/5] refactor: use getLocFromIndex in ShellSourceCode Replaces the hand-rolled line index and binary search with the getLocFromIndex() method inherited from TextSourceCodeBase, so there is a single line model for locations and sourceCode.lines. Co-Authored-By: Claude Opus 5.5 --- src/languages/shell-source-code.spec.ts | 25 +++++++++++ src/languages/shell-source-code.ts | 60 +++++++------------------ 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/languages/shell-source-code.spec.ts b/src/languages/shell-source-code.spec.ts index 3268ef5..1ea7656 100644 --- a/src/languages/shell-source-code.spec.ts +++ b/src/languages/shell-source-code.spec.ts @@ -62,6 +62,31 @@ describe("ShellSourceCode", () => { expect(loc.start).toEqual({ line: 1, column: 10 }); }); + + it("should compute the location of the Program node", () => { + for (const [text, end] of [ + ["", { line: 1, column: 1 }], + ["echo a\necho b", { line: 2, column: 7 }], + ["echo a\r\necho b\r\n", { line: 3, column: 1 }], + ] as const) { + const sourceCode = createSourceCode(text); + + expect(sourceCode.getLoc(sourceCode.ast)).toEqual({ + start: { line: 1, column: 1 }, + end, + }); + } + }); + + it("should compute locations in files with CRLF line endings", () => { + const sourceCode = createSourceCode("echo a\r\necho b\r\n"); + const second = sourceCode.ast.body[1]!; + + expect(sourceCode.getLoc(second)).toEqual({ + start: { line: 2, column: 1 }, + end: { line: 2, column: 7 }, + }); + }); }); describe("getText", () => { diff --git a/src/languages/shell-source-code.ts b/src/languages/shell-source-code.ts index 00efcb0..70853e7 100644 --- a/src/languages/shell-source-code.ts +++ b/src/languages/shell-source-code.ts @@ -61,7 +61,6 @@ export class ShellSourceCode extends TextSourceCodeBase<{ #parents = new Map(); #steps: VisitNodeStep[] | null = null; - #lineOffsets: number[] | null = null; #inlineConfigComments: CommentNode[] | null = null; constructor({ text, ast }: ShellSourceCodeOptions) { @@ -69,51 +68,26 @@ export class ShellSourceCode extends TextSourceCodeBase<{ 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; - } + getLoc(node: ShellNode): SourceLocation { + /* + * `getLocFromIndex()` asks for the location of the root node, so + * that one is computed directly. `Program` always spans the file. + */ + if (node === this.ast) { + const lines = this.lines; + + return { + start: { line: 1, column: 1 }, + end: { + line: lines.length, + column: (lines.at(-1) as string).length + 1, + }, + }; } return { - line: low + 1, - column: index - (lineStarts[low] as number) + 1, - }; - } - - getLoc(node: ShellNode): SourceLocation { - return { - start: this.#locFromIndex(node.start), - end: this.#locFromIndex(node.end), + start: this.getLocFromIndex(node.start), + end: this.getLocFromIndex(node.end), }; }