shout/src/parse.test.ts
Chris Wanstrath 724f40c25d Allow @def body to start on the next continuation line
Previously, a backslash immediately after the macro name (with no
body on the first line) produced a leading newline. Now an empty
prefix is handled so the body begins cleanly on the continuation line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:50:01 -07:00

353 lines
14 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { parse, parseSetup } from "./parse.ts"
describe("parse", () => {
test("simple command with output", () => {
const result = parse("test.shout", "$ echo hello\nhello\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("echo hello")
expect(result.commands[0]!.expected).toEqual(["hello"])
expect(result.commands[0]!.exitCode).toBeNull()
})
test("multiple commands", () => {
const content = "$ echo one\none\n\n$ echo two\ntwo\n"
const result = parse("test.shout", content)
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual(["one"])
expect(result.commands[1]!.expected).toEqual(["two"])
})
test("command with no expected output", () => {
const result = parse("test.shout", "$ export FOO=bar\n$ echo $FOO\nbar\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual([])
expect(result.commands[1]!.expected).toEqual(["bar"])
})
test("strips trailing comment from command", () => {
const result = parse("test.shout", '$ echo hello # a comment\nhello\n')
expect(result.commands[0]!.command).toBe("echo hello")
expect(result.commands[0]!.raw).toBe("$ echo hello # a comment")
})
test("preserves # inside quotes", () => {
const result = parse("test.shout", '$ echo "keep # this"\nkeep # this\n')
expect(result.commands[0]!.command).toBe('echo "keep # this"')
})
test("exit code [N]", () => {
const result = parse("test.shout", "$ false\n[1]\n")
expect(result.commands[0]!.exitCode).toBe(1)
expect(result.commands[0]!.expected).toEqual([])
})
test("exit code [*]", () => {
const result = parse("test.shout", "$ false\noops\n[*]\n")
expect(result.commands[0]!.exitCode).toBe("*")
expect(result.commands[0]!.expected).toEqual(["oops"])
})
test("exit code [42] with output", () => {
const result = parse("test.shout", "$ sh -c 'echo err && exit 42'\nerr\n[42]\n")
expect(result.commands[0]!.exitCode).toBe(42)
expect(result.commands[0]!.expected).toEqual(["err"])
})
test("blank lines in expected output", () => {
const content = '$ echo -e "a\\n\\nb"\na\n\nb\n'
const result = parse("test.shout", content)
expect(result.commands[0]!.expected).toEqual(["a", "", "b"])
})
test("trailing newline ignored", () => {
const a = parse("test.shout", "$ echo hi\nhi\n")
const b = parse("test.shout", "$ echo hi\nhi")
expect(a.commands[0]!.expected).toEqual(b.commands[0]!.expected)
})
test("@env directive", () => {
const result = parse("test.shout", "@env PORT=3000\n$ echo $PORT\n3000\n")
expect(result.directives).toEqual([
{ type: "env", key: "PORT", value: "3000", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("@env with value containing =", () => {
const result = parse("test.shout", "@env FOO=bar=baz\n$ echo $FOO\n")
expect(result.directives[0]).toEqual(
{ type: "env", key: "FOO", value: "bar=baz", line: 1 },
)
})
test("@setup directive", () => {
const result = parse("test.shout", "@setup shared/setup.shout\n$ echo hi\nhi\n")
expect(result.directives).toEqual([
{ type: "setup", path: "shared/setup.shout", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("multiple directives", () => {
const content = "@setup setup.shout\n@env PORT=3000\n@env NODE_ENV=test\n\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
expect(result.directives).toHaveLength(3)
expect(result.directives[0]!.type).toBe("setup")
expect(result.directives[1]).toEqual({ type: "env", key: "PORT", value: "3000", line: 2 })
expect(result.directives[2]).toEqual({ type: "env", key: "NODE_ENV", value: "test", line: 3 })
})
test("@ lines after first command are expected output", () => {
const result = parse("test.shout", "$ cat config\n@env PORT=3000\n")
expect(result.directives).toEqual([])
expect(result.commands[0]!.expected).toEqual(["@env PORT=3000"])
})
test("escaped dollar sign in expected output", () => {
const content = "$ echo '$ hello'\n\\$ hello\n"
const result = parse("test.shout", content)
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["$ hello"])
})
test("multiple escaped dollar signs", () => {
const content = "$ printf '$ a\\n$ b\\n'\n\\$ a\n\\$ b\n"
const result = parse("test.shout", content)
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["$ a", "$ b"])
})
test("escaped dollar before first command is ignored", () => {
const content = "\\$ not a command\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
// \$ before any command — no current command to attach to, so skipped
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["hi"])
})
test("$# comment line is skipped", () => {
const result = parse("test.shout", "$# start the server\n$ echo hi\nhi\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("echo hi")
})
test("$# comment between commands", () => {
const result = parse("test.shout", "$ echo one\none\n$# now do two\n$ echo two\ntwo\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual(["one"])
expect(result.commands[1]!.expected).toEqual(["two"])
})
test("$# comment with space after hash", () => {
const result = parse("test.shout", "$ # server setup\n$ echo hi\nhi\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("echo hi")
})
test("$# comment as last line", () => {
const result = parse("test.shout", "$ echo hi\nhi\n$# done\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["hi"])
})
test("output after $# comment is ignored", () => {
const result = parse("test.shout", "$ echo hi\nhi\n$# comment\nstray line\n$ echo bye\nbye\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual(["hi"])
expect(result.commands[1]!.expected).toEqual(["bye"])
})
test("no directives returns empty array", () => {
const result = parse("test.shout", "$ echo hi\nhi\n")
expect(result.directives).toEqual([])
})
test("@teardown in .shout file", () => {
const result = parse("test.shout", "@teardown rm -f /tmp/test.db\n$ echo hi\nhi\n")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
expect(result.commands).toHaveLength(1)
})
test("@teardown with @setup in .shout file", () => {
const content = "@setup setup.shout\n@teardown rm -f /tmp/test.db\n@env PORT=3000\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
expect(result.directives).toHaveLength(2)
expect(result.directives[0]!.type).toBe("setup")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("unknown directive throws", () => {
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
"test.shout:1: unknown directive: @evn PORT=3000",
)
})
test("@def simple macro", () => {
const result = parse("test.shout", "@def greet echo hello\n$ greet\nhello\n")
expect(result.directives).toEqual([
{ type: "def", name: "greet", body: "echo hello", line: 1 },
])
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("greet")
})
test("@def with backslash continuation", () => {
const content = "@def multi echo one; \\\n echo two\n$ multi\none\ntwo\n"
const result = parse("test.shout", content)
expect(result.directives).toEqual([
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("@def with body starting on continuation line", () => {
const content = "@def serve \\\n python3 -m http.server\n$ serve\n"
const result = parse("test.shout", content)
expect(result.directives).toEqual([
{ type: "def", name: "serve", body: "python3 -m http.server", line: 1 },
])
})
test("@def multiple macros", () => {
const content = "@def foo echo foo\n@def bar echo bar\n$ foo\nfoo\n"
const result = parse("test.shout", content)
expect(result.directives).toHaveLength(2)
expect(result.directives[0]).toEqual({ type: "def", name: "foo", body: "echo foo", line: 1 })
expect(result.directives[1]).toEqual({ type: "def", name: "bar", body: "echo bar", line: 2 })
})
test("@def without body throws", () => {
expect(() => parse("test.shout", "@def greet\n$ echo hi\n")).toThrow(
"test.shout:1: @def requires a name and body",
)
})
test("@def with whitespace-only body throws", () => {
expect(() => parse("test.shout", "@def greet \n$ echo hi\n")).toThrow(
"test.shout:1: @def requires a name and body",
)
})
test("@def continuation consuming $ line throws", () => {
expect(() => parse("test.shout", "@def foo echo a \\\n$ echo real\n")).toThrow(
"test.shout:2: @def continuation consumed a command or directive line",
)
})
test("@def continuation consuming @ directive throws", () => {
expect(() => parse("test.shout", "@def foo echo a \\\n@env PORT=3000\n")).toThrow(
"test.shout:2: @def continuation consumed a command or directive line",
)
})
test("@def trailing backslash with no continuation throws", () => {
expect(() => parse("test.shout", "@def foo echo a \\\n")).toThrow(
"test.shout:1: @def ends with \\ but has no continuation line",
)
})
test("@def continuation consuming blank line throws", () => {
expect(() => parse("test.shout", "@def foo echo a \\\n\n$ echo real\n")).toThrow(
"test.shout:2: @def continuation consumed a command or directive line",
)
})
test("@def continuation consuming comment line throws", () => {
expect(() => parse("test.shout", "@def foo echo a \\\n# comment\n$ echo real\n")).toThrow(
"test.shout:2: @def continuation consumed a command or directive line",
)
})
test("@def after first command is expected output", () => {
const result = parse("test.shout", "$ cat file\n@def foo bar\n")
expect(result.directives).toEqual([])
expect(result.commands[0]!.expected).toEqual(["@def foo bar"])
})
})
describe("parseSetup", () => {
test("plain commands without $ prefix", () => {
const result = parseSetup("setup.shout", "export FOO=bar\necho hello\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.commands[1]!.command).toBe("echo hello")
})
test("@env directives", () => {
const result = parseSetup("setup.shout", "@env PORT=3000\nexport FOO=bar\n")
expect(result.directives).toEqual([
{ type: "env", key: "PORT", value: "3000", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("blank lines and comments are ignored", () => {
const result = parseSetup("setup.shout", "# set up env\nexport FOO=bar\n\nexport BAZ=qux\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.commands[1]!.command).toBe("export BAZ=qux")
})
test("strips trailing comments from commands", () => {
const result = parseSetup("setup.shout", "export FOO=bar # set foo\n")
expect(result.commands[0]!.command).toBe("export FOO=bar")
})
test("@setup in setup file throws", () => {
expect(() => parseSetup("setup.shout", "@setup other.shout\n")).toThrow(
"setup.shout:1: @setup not allowed in setup files",
)
})
test("@teardown commands in setup file", () => {
const result = parseSetup("setup.shout", "export FOO=bar\n@teardown rm -f /tmp/test.db\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("@teardown strips trailing comment", () => {
const result = parseSetup("setup.shout", "@teardown rm -f /tmp/test.db # cleanup\n")
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("@teardown with empty command throws", () => {
expect(() => parseSetup("setup.shout", "@teardown \n")).toThrow(
"setup.shout:1: @teardown requires a command",
)
})
test("multiple @teardown commands", () => {
const result = parseSetup("setup.shout", "@teardown rm -f a.db\n@teardown rm -f b.db\n")
expect(result.teardownCommands).toHaveLength(2)
expect(result.teardownCommands[0]!.command).toBe("rm -f a.db")
expect(result.teardownCommands[1]!.command).toBe("rm -f b.db")
})
test("commands have no expected output", () => {
const result = parseSetup("setup.shout", "echo hello\n")
expect(result.commands[0]!.expected).toEqual([])
expect(result.commands[0]!.exitCode).toBeNull()
})
test("@def in setup file", () => {
const result = parseSetup("setup.shout", "@def greet echo hello\nexport FOO=bar\n")
expect(result.directives).toEqual([
{ type: "def", name: "greet", body: "echo hello", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("@def with backslash continuation in setup file", () => {
const result = parseSetup("setup.shout", "@def multi echo one; \\\n echo two\n")
expect(result.directives).toEqual([
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
])
})
})