Compare commits

...

5 Commits

Author SHA1 Message Date
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
9943641c02 Allow @def directives in setup files and fix macro precedence
User macros now correctly override setup macros instead of
last-write-wins. Also hardens @def continuation parsing to reject
trailing backslash at EOF, blank lines, and comment lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:24:37 -07:00
7231c07a8b Apply macro expansion to teardown commands and fix @def continuation parsing
Teardown commands were missing the expandMacro call that setup and
parsed commands already received. Also relax the continuation-line
guard to catch bare `$` (not just `$ `) so lines like `$VAR` are
not silently consumed as continuation text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:59:08 -07:00
f508e30fcb Merge setup and user macros into a single map and harden @def parsing
User macros already overwrote setup macros, so two separate maps were
unnecessary. Also prevents @def continuations from silently swallowing
command or directive lines, and stops expanding macros in teardown.
2026-03-19 11:50:15 -07:00
ce1503a9d4 Add @def directive for command macro substitution
Macros let setup files define reusable command shorthands that
test files can invoke by name or override with their own definition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:28:59 -07:00
10 changed files with 215 additions and 2 deletions

View File

@ -47,9 +47,14 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- Can appear in both `.shout` files and setup files
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file
- Setup files use a plain format: each line is a command (no `$ ` prefix), `#` lines are comments, blank lines ignored
- Setup files can contain `@env` and `@teardown` directives but not `@setup` (no nesting)
- Setup files can contain `@env`, `@teardown`, and `@def` directives but not `@setup` (no nesting)
- User file `@env` overrides setup file `@env`
- Setup command failures abort the test with an error
- `@def name body` before first command = define a macro
- If a command matches `name` exactly, `body` is substituted before execution
- Backslash `\` at end of line continues the body onto the next line
- Allowed in both `.shout` files and setup files
- User file `@def` overrides setup file `@def` with the same name
- Each file runs in a fresh temp dir with a single `/bin/sh` session
- `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically
- `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file

View File

@ -99,6 +99,29 @@ export DB_URL=sqlite:data/test.db
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db"
```
### `@def`
Define a macro that substitutes a command by name:
```
@def greet echo "hello world"
$ greet
hello world
```
Use backslash `\` for multi-line bodies. The body can start on the same line or on the next continuation line:
```
@def serve \
python3 -m http.server $PORT & \
sleep 0.5
$ serve
```
Macros defined in setup files are inherited. A user file `@def` with the same name overrides the setup version.
```
Usage: shout test [options] [files...]

View File

@ -117,6 +117,8 @@ program
const envVars: Record<string, string> = {}
const setupEnvVars: Record<string, string> = {}
const userEnvVars: Record<string, string> = {}
const setupMacros: Record<string, string> = {}
const userMacros: Record<string, string> = {}
const setupCommands: Command[] = []
const teardownCommands: Command[] = [...parsed.teardownCommands]
for (const d of parsed.directives) {
@ -126,18 +128,29 @@ program
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
for (const sd of setupParsed.directives) {
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
else if (sd.type === "def") setupMacros[sd.name] = sd.body
}
setupCommands.push(...setupParsed.commands)
teardownCommands.push(...setupParsed.teardownCommands)
} else if (d.type === "env") {
userEnvVars[d.key] = d.value
} else if (d.type === "def") {
userMacros[d.name] = d.body
}
}
Object.assign(envVars, setupEnvVars, userEnvVars)
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
envVars["PORT"] = String(port)
}
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] }
const macros: Record<string, string> = Object.assign({}, setupMacros, userMacros)
const expandMacro = (cmd: Command): Command => {
const body = macros[cmd.command]
return body !== undefined ? { ...cmd, command: body } : cmd
}
const merged: ShoutFile = {
...parsed,
commands: [...setupCommands, ...parsed.commands, ...teardownCommands].map(expandMacro),
}
const setupLen = setupCommands.length
const userLen = parsed.commands.length

View File

@ -185,6 +185,88 @@ describe("parse", () => {
"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", () => {
@ -252,4 +334,19 @@ describe("parseSetup", () => {
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 },
])
})
})

View File

@ -11,6 +11,7 @@ export type Command = {
export type Directive =
| { type: "setup"; path: string; line: number }
| { type: "env"; key: string; value: string; line: number }
| { type: "def"; name: string; body: string; line: number }
export type ShoutFile = {
path: string
@ -65,6 +66,39 @@ function parseEnvDirective(path: string, line: string, lineNum: number): { key:
return { key: rest.slice(0, eq), value: rest.slice(eq + 1) }
}
function parseDefDirective(
path: string,
rawLines: string[],
i: number,
): { name: string; body: string; linesConsumed: number } {
const rest = rawLines[i]!.slice(5).trim() // strip "@def "
const spaceIdx = rest.indexOf(" ")
if (spaceIdx < 0) {
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
}
const name = rest.slice(0, spaceIdx)
let body = rest.slice(spaceIdx + 1).trim()
if (!body) {
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
}
let extra = 0
while (body.endsWith("\\")) {
if (i + extra + 1 >= rawLines.length) {
throw new Error(`${path}:${i + extra + 1}: @def ends with \\ but has no continuation line`)
}
const next = rawLines[i + extra + 1]!
if (next.startsWith("$") || next.startsWith("@") || next.trim() === "" || next.startsWith("#")) {
throw new Error(`${path}:${i + extra + 2}: @def continuation consumed a command or directive line`)
}
const prefix = body.slice(0, -1).trimEnd()
body = prefix ? prefix + "\n" + next.trim() : next.trim()
extra++
}
return { name, body, linesConsumed: extra }
}
export function parseSetup(path: string, content: string): ShoutFile {
const rawLines = content.split("\n")
@ -97,6 +131,10 @@ export function parseSetup(path: string, content: string): ShoutFile {
expected: [],
exitCode: null,
})
} else if (line.startsWith("@def ")) {
const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i)
directives.push({ type: "def", name, body, line: i + 1 })
i += linesConsumed
} else if (line.startsWith("@setup ")) {
throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`)
} else {
@ -156,6 +194,10 @@ export function parse(path: string, content: string): ShoutFile {
} else if (line.startsWith("@env ")) {
const { key, value } = parseEnvDirective(path, line, i + 1)
directives.push({ type: "env", key, value, line: i + 1 })
} else if (line.startsWith("@def ")) {
const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i)
directives.push({ type: "def", name, body, line: i + 1 })
i += linesConsumed
} else {
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
}

5
test/def-override.shout Normal file
View File

@ -0,0 +1,5 @@
@setup def-shared.shout
@def serve echo "overridden"
$ serve
overridden

4
test/def-setup.shout Normal file
View File

@ -0,0 +1,4 @@
@setup def-shared.shout
$ serve
server started

1
test/def-shared.shout Normal file
View File

@ -0,0 +1 @@
@def serve echo "server started"

13
test/def.shout Normal file
View File

@ -0,0 +1,13 @@
@def greet echo "hello world"
@def multi echo "line one"; \
echo "line two"
$ greet
hello world
$ multi
line one
line two
$ echo "not a macro"
not a macro

View File

@ -218,6 +218,16 @@
<p><code>@teardown</code> can appear in both <code>.shout</code> files and setup files. Teardown failures produce warnings but don't affect test results.</p>
</section>
<section>
<h2>Macros</h2>
<p>Use <code class="bright">@def</code> to define reusable command macros.</p>
<pre><code><span class="bright">@def</span> <span class="cmd">greet echo "hello world"</span>
<span class="prompt">$</span> <span class="cmd">greet</span>
<span class="output">hello world</span></code></pre>
<p>If a command matches a macro name exactly, the body is substituted. Use <code>\</code> for multi-line bodies — the body can start on the same line or on the next continuation line. Macros from setup files are inherited; user-file macros override them.</p>
</section>
<section>
<h2>Run it</h2>
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span>