From 2c0e739be143efc45bd8ede157037e97fa9d5ff7 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 18 Sep 2026 10:35:50 -0400 Subject: [PATCH 1/3] chore: add rule helper utilities Co-Authored-By: Claude Opus 5 --- src/rules/utils.spec.ts | 104 ++++++++++++++++++++++++++++++++++++ src/rules/utils.ts | 113 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/rules/utils.spec.ts create mode 100644 src/rules/utils.ts diff --git a/src/rules/utils.spec.ts b/src/rules/utils.spec.ts new file mode 100644 index 0000000..21c55ff --- /dev/null +++ b/src/rules/utils.spec.ts @@ -0,0 +1,104 @@ +/** + * @fileoverview Tests for rule helper utilities. + */ + +import { describe, expect, it } from "vitest"; +import { parseBash } from "../parser/parse.js"; +import { + getCommandName, + getExpansions, + getStaticText, + isCommandNamed, +} from "./utils.js"; +import type { CommandNode, WordNode } from "../types.js"; + +function firstCommand(text: string): CommandNode { + return parseBash(text).ast.body[0] as CommandNode; +} + +function firstArgument(text: string): WordNode { + const word = firstCommand(text).arguments[0]; + + if (!word) { + throw new Error("Expected an argument"); + } + + return word; +} + +describe("getStaticText", () => { + it("should return plain literal text", () => { + expect(getStaticText(firstArgument("echo hello\n"))).toBe("hello"); + }); + + it("should unquote single-quoted text", () => { + expect(getStaticText(firstArgument("echo 'a b'\n"))).toBe("a b"); + }); + + it("should unquote double-quoted literal text", () => { + expect(getStaticText(firstArgument('echo "a b"\n'))).toBe("a b"); + }); + + it("should join mixed static parts", () => { + expect(getStaticText(firstArgument("echo pre'mid'\"post\"\n"))).toBe( + "premidpost", + ); + }); + + it("should return null for words with expansions", () => { + expect(getStaticText(firstArgument("echo $var\n"))).toBeNull(); + expect(getStaticText(firstArgument('echo "x$var"\n'))).toBeNull(); + expect(getStaticText(firstArgument("echo $(pwd)\n"))).toBeNull(); + }); +}); + +describe("getCommandName", () => { + it("should return the static command name", () => { + expect(getCommandName(firstCommand("echo hi\n"))).toBe("echo"); + }); + + it("should return null for dynamic names", () => { + expect(getCommandName(firstCommand("$cmd hi\n"))).toBeNull(); + }); + + it("should return null for assignment-only commands", () => { + expect(getCommandName(firstCommand("x=1\n"))).toBeNull(); + }); +}); + +describe("isCommandNamed", () => { + it("should match commands by name", () => { + expect(isCommandNamed(firstCommand("cd /tmp\n"), "cd")).toBe(true); + expect(isCommandNamed(firstCommand("cd /tmp\n"), "ls")).toBe(false); + }); + + it("should not match non-command statements", () => { + const statement = parseBash("a | b\n").ast.body[0]!; + + expect(isCommandNamed(statement, "a")).toBe(false); + }); +}); + +describe("getExpansions", () => { + it("should return top-level expansions", () => { + const expansions = getExpansions(firstArgument("echo a$b$(c)\n")); + + expect(expansions).toHaveLength(2); + expect(expansions[0]?.type).toBe("ParameterExpansion"); + expect(expansions[1]?.type).toBe("CommandSubstitution"); + }); + + it("should skip quoted expansions by default", () => { + const expansions = getExpansions(firstArgument('echo "$a"\n')); + + expect(expansions).toHaveLength(0); + }); + + it("should include quoted expansions when requested", () => { + const expansions = getExpansions(firstArgument('echo "$a$(b)"\n'), { + includeQuoted: true, + }); + + expect(expansions).toHaveLength(2); + }); +}); diff --git a/src/rules/utils.ts b/src/rules/utils.ts new file mode 100644 index 0000000..cc5ff22 --- /dev/null +++ b/src/rules/utils.ts @@ -0,0 +1,113 @@ +/** + * @fileoverview Shared helpers for Bash rules. + */ + +import type { + CommandNode, + CommandSubstitutionNode, + ParameterExpansionNode, + StatementNode, + WordNode, + WordPartNode, +} from "../types.js"; + +/** + * Returns the plain text of a word when it is fully static (made up only of + * literals and quoted literals), or `null` when the word contains + * expansions. Quotes are removed from the result. + */ +export function getStaticText(word: WordNode): string | null { + let result = ""; + + for (const part of word.parts) { + const text = getStaticPartText(part); + + if (text === null) { + return null; + } + + result += text; + } + + return result; +} + +function getStaticPartText(part: WordPartNode): string | null { + switch (part.type) { + case "Literal": + return part.value; + + case "SingleQuotedString": + return part.value; + + case "DoubleQuotedString": { + let result = ""; + + for (const inner of part.parts) { + const text = getStaticPartText(inner); + + if (text === null) { + return null; + } + + result += text; + } + + return result; + } + + default: + return null; + } +} + +/** + * Returns the name of a command when it is a static word, or `null`. + */ +export function getCommandName(command: CommandNode): string | null { + if (!command.name) { + return null; + } + + return getStaticText(command.name); +} + +/** + * Determines whether a command node is a simple command with the given name. + */ +export function isCommandNamed( + statement: StatementNode, + name: string, +): statement is CommandNode { + return statement.type === "Command" && getCommandName(statement) === name; +} + +/** + * Returns all expansions in a word, including those nested inside double + * quotes when `includeQuoted` is `true`. + */ +export function getExpansions( + word: WordNode, + { includeQuoted = false } = {}, +): (ParameterExpansionNode | CommandSubstitutionNode)[] { + const expansions: (ParameterExpansionNode | CommandSubstitutionNode)[] = []; + + const visitParts = (parts: WordPartNode[], quoted: boolean): void => { + for (const part of parts) { + if ( + part.type === "ParameterExpansion" || + part.type === "CommandSubstitution" + ) { + if (!quoted || includeQuoted) { + expansions.push(part); + } + } else if (part.type === "DoubleQuotedString") { + visitParts(part.parts, true); + } + } + }; + + visitParts(word.parts, false); + + return expansions; +} From 09e36083c4e19be888e29b09658a00f0fc45e4a7 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 1 Oct 2026 13:21:43 -0400 Subject: [PATCH 2/3] fix: treat escaped and translated strings as non-static getStaticText() now returns null for $'...' strings containing escapes and for locale-translated $"..." strings, whose runtime text can differ from the source text. Co-Authored-By: Claude Opus 5.5 --- src/rules/utils.spec.ts | 25 +++++++++++++++++++++++++ src/rules/utils.ts | 15 ++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/rules/utils.spec.ts b/src/rules/utils.spec.ts index 21c55ff..c3ff309 100644 --- a/src/rules/utils.spec.ts +++ b/src/rules/utils.spec.ts @@ -45,6 +45,20 @@ describe("getStaticText", () => { ); }); + it("should return the text of dollar-quoted strings without escapes", () => { + expect(getStaticText(firstArgument("echo $'foo bar'\n"))).toBe( + "foo bar", + ); + }); + + it("should return null for dollar-quoted strings with escapes", () => { + expect(getStaticText(firstArgument("echo $'e\\x63ho'\n"))).toBeNull(); + }); + + it("should return null for locale-translated strings", () => { + expect(getStaticText(firstArgument('echo $"foo"\n'))).toBeNull(); + }); + it("should return null for words with expansions", () => { expect(getStaticText(firstArgument("echo $var\n"))).toBeNull(); expect(getStaticText(firstArgument('echo "x$var"\n'))).toBeNull(); @@ -57,6 +71,10 @@ describe("getCommandName", () => { expect(getCommandName(firstCommand("echo hi\n"))).toBe("echo"); }); + it("should return null for names that need unescaping", () => { + expect(getCommandName(firstCommand("$'e\\x63ho' hi\n"))).toBeNull(); + }); + it("should return null for dynamic names", () => { expect(getCommandName(firstCommand("$cmd hi\n"))).toBeNull(); }); @@ -88,6 +106,13 @@ describe("getExpansions", () => { expect(expansions[1]?.type).toBe("CommandSubstitution"); }); + it("should not return expansions nested inside other expansions", () => { + const expansions = getExpansions(firstArgument("echo ${a:-$b}\n")); + + expect(expansions).toHaveLength(1); + expect(expansions[0]).toMatchObject({ name: "a" }); + }); + it("should skip quoted expansions by default", () => { const expansions = getExpansions(firstArgument('echo "$a"\n')); diff --git a/src/rules/utils.ts b/src/rules/utils.ts index cc5ff22..07eae4c 100644 --- a/src/rules/utils.ts +++ b/src/rules/utils.ts @@ -15,6 +15,10 @@ import type { * Returns the plain text of a word when it is fully static (made up only of * literals and quoted literals), or `null` when the word contains * expansions. Quotes are removed from the result. + * + * Strings whose runtime text can differ from their source text are not + * static: `$'...'` strings containing escapes, which are not decoded, and + * locale-translated `$"..."` strings. */ export function getStaticText(word: WordNode): string | null { let result = ""; @@ -38,9 +42,13 @@ function getStaticPartText(part: WordPartNode): string | null { return part.value; case "SingleQuotedString": - return part.value; + return part.dollar && part.value.includes("\\") ? null : part.value; case "DoubleQuotedString": { + if (part.dollar) { + return null; + } + let result = ""; for (const inner of part.parts) { @@ -83,8 +91,9 @@ export function isCommandNamed( } /** - * Returns all expansions in a word, including those nested inside double - * quotes when `includeQuoted` is `true`. + * Returns the expansions that make up a word, including those nested inside + * double quotes when `includeQuoted` is `true`. Expansions nested inside + * another expansion, such as `$b` in `${a:-$b}`, are not returned. */ export function getExpansions( word: WordNode, From b8ca9690e93c7f22176f41f653a23509f3b5b7a9 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 8 Oct 2026 15:39:37 -0400 Subject: [PATCH 3/3] fix: use Shell type names in rule helper utilities Co-Authored-By: Claude Opus 5.5 --- src/rules/utils.spec.ts | 6 +++--- src/rules/utils.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rules/utils.spec.ts b/src/rules/utils.spec.ts index c3ff309..5b8f513 100644 --- a/src/rules/utils.spec.ts +++ b/src/rules/utils.spec.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from "vitest"; -import { parseBash } from "../parser/parse.js"; +import { parseShell } from "../parser/parse.js"; import { getCommandName, getExpansions, @@ -13,7 +13,7 @@ import { import type { CommandNode, WordNode } from "../types.js"; function firstCommand(text: string): CommandNode { - return parseBash(text).ast.body[0] as CommandNode; + return parseShell(text).ast.body[0] as CommandNode; } function firstArgument(text: string): WordNode { @@ -91,7 +91,7 @@ describe("isCommandNamed", () => { }); it("should not match non-command statements", () => { - const statement = parseBash("a | b\n").ast.body[0]!; + const statement = parseShell("a | b\n").ast.body[0]!; expect(isCommandNamed(statement, "a")).toBe(false); }); diff --git a/src/rules/utils.ts b/src/rules/utils.ts index 07eae4c..40d3bd0 100644 --- a/src/rules/utils.ts +++ b/src/rules/utils.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Shared helpers for Bash rules. + * @fileoverview Shared helpers for shell rules. */ import type {