diff --git a/src/rules/utils.spec.ts b/src/rules/utils.spec.ts new file mode 100644 index 0000000..5b8f513 --- /dev/null +++ b/src/rules/utils.spec.ts @@ -0,0 +1,129 @@ +/** + * @fileoverview Tests for rule helper utilities. + */ + +import { describe, expect, it } from "vitest"; +import { parseShell } 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 parseShell(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 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(); + 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 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(); + }); + + 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 = parseShell("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 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')); + + 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..40d3bd0 --- /dev/null +++ b/src/rules/utils.ts @@ -0,0 +1,122 @@ +/** + * @fileoverview Shared helpers for shell 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. + * + * 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 = ""; + + 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.dollar && part.value.includes("\\") ? null : part.value; + + case "DoubleQuotedString": { + if (part.dollar) { + return null; + } + + 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 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, + { 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; +}