diff --git a/README.md b/README.md index 5ab68d0..9993376 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,64 @@ npm install --save-dev eslint @eslint/shell Requires Node.js `^20.19.0 || ^22.13.0 || >=24`. Tested with ESLint v10. +## Usage + +Add the plugin to your `eslint.config.js`: + +```js +import shell from "@eslint/shell"; + +export default [ + // use the recommended rules for *.sh and *.bash files + shell.configs.recommended, +]; +``` + +### Languages + +The plugin provides one language per shell dialect: + +| Language | Dialect | +| ------------- | -------- | +| `shell/bash` | Bash | +| `shell/posix` | POSIX sh | +| `shell/mksh` | mksh | + +The recommended configuration uses `shell/bash`. To lint scripts written for +another dialect, set `language` yourself: + +```js +export default [ + { + files: ["**/*.sh"], + plugins: { shell }, + language: "shell/posix", + }, +]; +``` + +### Language options + +| Option | Values | Default | Description | +| --------- | ----------------------------- | ---------------------- | -------------------------- | +| `variant` | `"bash"`, `"posix"`, `"mksh"` | The language's dialect | The shell dialect to parse | + +Each language sets `variant` to its own dialect, so you only need this option +to override the dialect of the language you chose. + +## Configuration comments + +Standard ESLint configuration comments work inside shell scripts: + +```bash +# eslint-disable-next-line shell/no-backticks +echo `pwd` + +echo `pwd` # eslint-disable-line shell/no-backticks -- legacy + +# eslint shell/no-useless-echo: "warn" +``` + ## Syntax tree The tree format is documented in [docs/syntax-tree.md](docs/syntax-tree.md). diff --git a/src/index.spec.ts b/src/index.spec.ts index 786f2fa..64f400c 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,46 @@ describe("plugin", () => { expect(parseShell("echo hi\n").ast.type).toBe("Program"); expect(() => parseShell("if then fi\n")).toThrow(ShellSyntaxError); }); + + it("should expose a language for each shell variant", () => { + expect(Object.keys(plugin.languages).sort()).toEqual([ + "bash", + "mksh", + "posix", + ]); + + for (const [variant, language] of Object.entries(plugin.languages)) { + expect(language).toBeInstanceOf(ShellLanguage); + expect(language.defaultLanguageOptions).toEqual({ variant }); + } + }); + + it("should expose all rules", () => { + const ruleIds = Object.keys(plugin.rules); + + expect(ruleIds.sort()).toEqual([]); + }); + + describe("recommended config", () => { + const recommended = plugin.configs.recommended; + + it("should reference the plugin itself", () => { + expect(recommended.plugins).toHaveProperty("shell", plugin); + }); + + it("should use the bash language for shell files", () => { + expect(recommended.language).toBe("shell/bash"); + expect(recommended.files).toContain("**/*.sh"); + expect(recommended.files).toContain("**/*.bash"); + }); + + it("should configure every rule", () => { + const configured = Object.keys(recommended.rules).sort(); + const expected = Object.keys(plugin.rules) + .map(ruleId => `shell/${ruleId}`) + .sort(); + + expect(configured).toEqual(expected); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 6e55f77..68399eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,14 +3,39 @@ * ESLint, ShellCheck-inspired rules, and a recommended configuration. */ +import { ShellLanguage } from "./languages/shell-language.js"; + +const rules = {}; + const plugin = { meta: { name: "@eslint/shell", namespace: "shell", version: "0.0.0", // x-release-please-version }, + languages: { + bash: new ShellLanguage({ variant: "bash" }), + posix: new ShellLanguage({ variant: "posix" }), + mksh: new ShellLanguage({ variant: "mksh" }), + }, + rules, + configs: { + recommended: { + name: "shell/recommended", + files: ["**/*.sh", "**/*.bash"], + language: "shell/bash", + plugins: {}, + rules: {}, + }, + }, }; +// The recommended config must reference the plugin itself. +Object.assign(plugin.configs.recommended.plugins, { shell: plugin }); + export default plugin; +export { ShellLanguage } from "./languages/shell-language.js"; +export { ShellSourceCode } from "./languages/shell-source-code.js"; export { parseShell, ShellSyntaxError } from "./parser/parse.js"; +export { visitorKeys } from "./visitor-keys.js"; export type * from "./types.js"; diff --git a/src/languages/shell-language.spec.ts b/src/languages/shell-language.spec.ts new file mode 100644 index 0000000..a059eed --- /dev/null +++ b/src/languages/shell-language.spec.ts @@ -0,0 +1,160 @@ +/** + * @fileoverview Unit tests for ShellLanguage. + */ + +import { describe, expect, it } from "vitest"; +import { ShellLanguage } from "./shell-language.js"; +import { ShellSourceCode } from "./shell-source-code.js"; +import type { File } from "@eslint/core"; + +function createFile(body: string): File { + return { + path: "test.sh", + physicalPath: "test.sh", + bom: false, + body, + }; +} + +describe("ShellLanguage", () => { + const language = new ShellLanguage(); + + describe("metadata", () => { + it("should describe itself as a text language", () => { + expect(language.fileType).toBe("text"); + expect(language.lineStart).toBe(1); + expect(language.columnStart).toBe(1); + expect(language.nodeTypeKey).toBe("type"); + expect(language.visitorKeys).toHaveProperty("Program"); + expect(language.defaultLanguageOptions).toEqual({ + variant: "bash", + }); + }); + }); + + describe("constructor", () => { + it.each(["bash", "posix", "mksh"] as const)( + "should use the %s variant as the default language option", + variant => { + expect( + new ShellLanguage({ variant }).defaultLanguageOptions, + ).toEqual({ variant }); + }, + ); + + it("should reject unknown variants", () => { + expect( + () => + new ShellLanguage({ + // @ts-expect-error -- testing invalid input + variant: "fish", + }), + ).toThrow(TypeError); + }); + }); + + describe("validateLanguageOptions", () => { + it("should accept valid variants", () => { + expect(() => + language.validateLanguageOptions({ variant: "bash" }), + ).not.toThrow(); + expect(() => + language.validateLanguageOptions({ variant: "posix" }), + ).not.toThrow(); + expect(() => + language.validateLanguageOptions({ variant: "mksh" }), + ).not.toThrow(); + expect(() => language.validateLanguageOptions({})).not.toThrow(); + }); + + it("should reject unknown variants", () => { + expect(() => + language.validateLanguageOptions({ + // @ts-expect-error -- testing invalid input + variant: "fish", + }), + ).toThrow(TypeError); + }); + }); + + describe("parse", () => { + it("should return ok with an AST for valid input", () => { + const result = language.parse(createFile("echo hi\n")); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.ast.type).toBe("Program"); + } + }); + + it("should return errors with location for invalid input", () => { + const result = language.parse(createFile("echo ok\nif then fi\n")); + + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.line).toBe(2); + expect(result.errors[0]?.column).toBeGreaterThanOrEqual(1); + expect(result.errors[0]?.message.length).toBeGreaterThan(0); + } + }); + + it("should parse with the variant passed to the constructor", () => { + const file = createFile("diff <(sort a) <(sort b)\n"); + + expect(new ShellLanguage({ variant: "posix" }).parse(file).ok).toBe( + false, + ); + expect(new ShellLanguage({ variant: "bash" }).parse(file).ok).toBe( + true, + ); + + // `|&` with no following command starts a coprocess in mksh only. + const coprocess = createFile("cat |&\n"); + + expect( + new ShellLanguage({ variant: "mksh" }).parse(coprocess).ok, + ).toBe(true); + expect( + new ShellLanguage({ variant: "bash" }).parse(coprocess).ok, + ).toBe(false); + }); + + it("should respect the variant language option", () => { + const posixResult = language.parse( + createFile("diff <(sort a) <(sort b)\n"), + { languageOptions: { variant: "posix" } }, + ); + + expect(posixResult.ok).toBe(false); + + const bashResult = language.parse( + createFile("diff <(sort a) <(sort b)\n"), + { languageOptions: { variant: "bash" } }, + ); + + expect(bashResult.ok).toBe(true); + }); + }); + + describe("createSourceCode", () => { + it("should create a ShellSourceCode", () => { + const file = createFile("echo hi\n"); + const result = language.parse(file); + + expect(result.ok).toBe(true); + + if (result.ok) { + const sourceCode = language.createSourceCode(file, { + ...result, + comments: [], + }); + + expect(sourceCode).toBeInstanceOf(ShellSourceCode); + expect(sourceCode.text).toBe("echo hi\n"); + } + }); + }); +}); diff --git a/src/languages/shell-language.ts b/src/languages/shell-language.ts new file mode 100644 index 0000000..bda1271 --- /dev/null +++ b/src/languages/shell-language.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview The ShellLanguage class, the ESLint Language implementation + * for shell scripts. + */ + +import type { + File, + Language, + LanguageContext, + OkParseResult, + ParseResult, +} from "@eslint/core"; +import { ShellSyntaxError, parseShell } from "../parser/parse.js"; +import { ShellSourceCode } from "./shell-source-code.js"; +import { visitorKeys } from "../visitor-keys.js"; +import type { + ShellLanguageOptions, + ShellNode, + ShellVariant, + CommentNode, + ProgramNode, +} from "../types.js"; + +const SHELL_VARIANTS = new Set(["bash", "posix", "mksh"]); + +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. + */ +export class ShellLanguage implements Language<{ + LangOptions: ShellLanguageOptions; + Code: ShellSourceCode; + RootNode: ProgramNode; + Node: ShellNode; +}> { + fileType = "text" as const; + lineStart = 1 as const; + columnStart = 1 as const; + nodeTypeKey = "type"; + visitorKeys = visitorKeys; + + 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 ( + 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 } = parseShell(text, { + variant: + context?.languageOptions?.variant ?? + this.defaultLanguageOptions.variant, + path: file.path, + }); + + return { ok: true, ast, comments }; + } catch (error) { + if (error instanceof ShellSyntaxError) { + 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: ShellOkParseResult, + ): ShellSourceCode { + return new ShellSourceCode({ + text: file.body as string, + ast: parseResult.ast, + }); + } +} diff --git a/src/languages/shell-source-code.spec.ts b/src/languages/shell-source-code.spec.ts new file mode 100644 index 0000000..1ea7656 --- /dev/null +++ b/src/languages/shell-source-code.spec.ts @@ -0,0 +1,225 @@ +/** + * @fileoverview Unit tests for ShellSourceCode. + */ + +import { describe, expect, it } from "vitest"; +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 } = parseShell(text); + + return new ShellSourceCode({ text, ast }); +} + +describe("ShellSourceCode", () => { + 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 }); + }); + + 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", () => { + 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 shell/no-backticks", + "echo `pwd`", + "# a normal comment", + "# eslint shell/no-useless-echo: 'off'", + "", + ].join("\n"), + ); + const nodes = sourceCode.getInlineConfigNodes(); + + expect(nodes).toHaveLength(2); + }); + + it("should produce disable directives", () => { + const sourceCode = createSourceCode( + [ + "# eslint-disable shell/no-backticks -- legacy file", + "echo `pwd`", + "# eslint-enable shell/no-backticks", + "# eslint-disable-line", + "# eslint-disable-next-line shell/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("shell/no-backticks"); + expect(directives[0]?.justification).toBe("legacy file"); + }); + + it("should apply inline rule configuration", () => { + const sourceCode = createSourceCode( + '# 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({ + "shell/no-backticks": "warn", + }); + }); + + it("should report problems for malformed inline config", () => { + const sourceCode = createSourceCode( + "# eslint shell/no-backticks: oops(\necho hi\n", + ); + const { problems } = sourceCode.applyInlineConfig(); + + expect(problems.length).toBeGreaterThan(0); + }); + }); +}); + +describe("ShellSourceCode construction", () => { + it("should accept a manually built program", () => { + const ast: ProgramNode = { + type: "Program", + start: 0, + end: 0, + body: [], + comments: [], + }; + const sourceCode = new ShellSourceCode({ text: "", ast }); + + expect(sourceCode.ast).toBe(ast); + }); +}); diff --git a/src/languages/shell-source-code.ts b/src/languages/shell-source-code.ts new file mode 100644 index 0000000..70853e7 --- /dev/null +++ b/src/languages/shell-source-code.ts @@ -0,0 +1,248 @@ +/** + * @fileoverview The ShellSourceCode class, the SourceCode implementation that + * ESLint uses to interact with a parsed shell script. + */ + +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 { + ShellLanguageOptions, + ShellNode, + 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 ShellSourceCodeOptions { + text: string; + ast: ProgramNode; +} + +/** + * 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 ShellSourceCode extends TextSourceCodeBase<{ + LangOptions: ShellLanguageOptions; + RootNode: ProgramNode; + SyntaxElementWithLoc: ShellNode; + ConfigNode: CommentNode; +}> { + /** All comments found in the file, in source order. */ + comments: CommentNode[]; + + #parents = new Map(); + #steps: VisitNodeStep[] | null = null; + #inlineConfigComments: CommentNode[] | null = null; + + constructor({ text, ast }: ShellSourceCodeOptions) { + super({ text, ast }); + this.comments = ast.comments; + } + + 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 { + start: this.getLocFromIndex(node.start), + end: this.getLocFromIndex(node.end), + }; + } + + getRange(node: ShellNode): SourceRange { + return [node.start, node.end]; + } + + getParent(node: ShellNode): ShellNode | 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: ShellNode, + parent: ShellNode | 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 ShellNode, node); + } + } + } else if (child) { + visit(child as ShellNode, 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 shell/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..01f586b --- /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 { + ShellNode, + ShellRuleDefinition, + 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< + ShellRuleDefinition<{ MessageIds: "oops" }> + >().toHaveProperty("create"); + }); +}); diff --git a/src/types.ts b/src/types.ts index 887c927..7ddac83 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,73 @@ export type ShellNode = //------------------------------------------------------------------------------ export type ShellVariant = "bash" | "posix" | "mksh"; + +export interface ShellLanguageOptions { + /** The shell dialect to parse. Defaults to the variant of the language in use. */ + 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; + 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..45a8a4d --- /dev/null +++ b/src/visitor-keys.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Visitor keys describing the traversal order of the shell + * 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..b5bb549 --- /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 shell 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: { shell, test: testPlugin }, + language: "shell/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..5730626 --- /dev/null +++ b/tests/fixtures/test-plugin.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview A test-only plugin with rules that exercise the shell + * language independently of the rules shipped by the plugin. + */ + +import type { ShellRuleDefinition, CommandNode } from "../../src/index.js"; + +/** + * Creates a rule that reports every simple command with the given name. + */ +function createCommandRule( + commandName: string, +): ShellRuleDefinition<{ 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..2060bff --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,160 @@ +/** + * @fileoverview Integration tests running the plugin through the ESLint + * Linter API. + */ + +import { describe, expect, it } from "vitest"; +import { Linter } from "eslint"; +import shell from "../src/index.js"; +import testPlugin from "./fixtures/test-plugin.js"; + +function lint( + code: string, + rules: Record = {}, + languageOptions?: Record, + language = "shell/bash", +): Linter.LintMessage[] { + const linter = new Linter(); + + return linter.verify( + code, + [ + { + files: ["**/*.sh"], + plugins: { shell, test: testPlugin }, + language, + 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("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(); + const messages = linter.verify( + "if then fi\n", + [shell.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", + [shell.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 = [shell.configs.recommended] as never; + + expect(linter.verify(code, config, "script.sh")[0]?.fatal).toBe(true); + expect(linter.verify(code, config, "script.js")).toEqual([]); + }); +});