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>
This commit is contained in:
parent
7231c07a8b
commit
9943641c02
|
|
@ -47,7 +47,7 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
||||||
- Can appear in both `.shout` files and setup files
|
- Can appear in both `.shout` files and setup files
|
||||||
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file
|
- `@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 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`
|
- User file `@env` overrides setup file `@env`
|
||||||
- Setup command failures abort the test with an error
|
- Setup command failures abort the test with an error
|
||||||
- `@def name body` before first command = define a macro
|
- `@def name body` before first command = define a macro
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,8 @@ program
|
||||||
const envVars: Record<string, string> = {}
|
const envVars: Record<string, string> = {}
|
||||||
const setupEnvVars: Record<string, string> = {}
|
const setupEnvVars: Record<string, string> = {}
|
||||||
const userEnvVars: Record<string, string> = {}
|
const userEnvVars: Record<string, string> = {}
|
||||||
const macros: Record<string, string> = {}
|
const setupMacros: Record<string, string> = {}
|
||||||
|
const userMacros: Record<string, string> = {}
|
||||||
const setupCommands: Command[] = []
|
const setupCommands: Command[] = []
|
||||||
const teardownCommands: Command[] = [...parsed.teardownCommands]
|
const teardownCommands: Command[] = [...parsed.teardownCommands]
|
||||||
for (const d of parsed.directives) {
|
for (const d of parsed.directives) {
|
||||||
|
|
@ -127,31 +128,28 @@ program
|
||||||
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
|
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
|
||||||
for (const sd of setupParsed.directives) {
|
for (const sd of setupParsed.directives) {
|
||||||
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
||||||
else if (sd.type === "def") macros[sd.name] = sd.body
|
else if (sd.type === "def") setupMacros[sd.name] = sd.body
|
||||||
}
|
}
|
||||||
setupCommands.push(...setupParsed.commands)
|
setupCommands.push(...setupParsed.commands)
|
||||||
teardownCommands.push(...setupParsed.teardownCommands)
|
teardownCommands.push(...setupParsed.teardownCommands)
|
||||||
} else if (d.type === "env") {
|
} else if (d.type === "env") {
|
||||||
userEnvVars[d.key] = d.value
|
userEnvVars[d.key] = d.value
|
||||||
} else if (d.type === "def") {
|
} else if (d.type === "def") {
|
||||||
macros[d.name] = d.body
|
userMacros[d.name] = d.body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Object.assign(envVars, setupEnvVars, userEnvVars)
|
Object.assign(envVars, setupEnvVars, userEnvVars)
|
||||||
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
|
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
|
||||||
envVars["PORT"] = String(port)
|
envVars["PORT"] = String(port)
|
||||||
}
|
}
|
||||||
|
const macros: Record<string, string> = Object.assign({}, setupMacros, userMacros)
|
||||||
const expandMacro = (cmd: Command): Command => {
|
const expandMacro = (cmd: Command): Command => {
|
||||||
const body = macros[cmd.command]
|
const body = macros[cmd.command]
|
||||||
return body !== undefined ? { ...cmd, command: body } : cmd
|
return body !== undefined ? { ...cmd, command: body } : cmd
|
||||||
}
|
}
|
||||||
const merged: ShoutFile = {
|
const merged: ShoutFile = {
|
||||||
...parsed,
|
...parsed,
|
||||||
commands: [
|
commands: [...setupCommands, ...parsed.commands, ...teardownCommands].map(expandMacro),
|
||||||
...setupCommands.map(expandMacro),
|
|
||||||
...parsed.commands.map(expandMacro),
|
|
||||||
...teardownCommands.map(expandMacro),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupLen = setupCommands.length
|
const setupLen = setupCommands.length
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,24 @@ describe("parse", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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", () => {
|
test("@def after first command is expected output", () => {
|
||||||
const result = parse("test.shout", "$ cat file\n@def foo bar\n")
|
const result = parse("test.shout", "$ cat file\n@def foo bar\n")
|
||||||
expect(result.directives).toEqual([])
|
expect(result.directives).toEqual([])
|
||||||
|
|
|
||||||
17
src/parse.ts
17
src/parse.ts
|
|
@ -77,23 +77,24 @@ function parseDefDirective(
|
||||||
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
||||||
}
|
}
|
||||||
const name = rest.slice(0, spaceIdx)
|
const name = rest.slice(0, spaceIdx)
|
||||||
let body = rest.slice(spaceIdx + 1)
|
let body = rest.slice(spaceIdx + 1).trim()
|
||||||
|
if (!body) {
|
||||||
|
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
||||||
|
}
|
||||||
let extra = 0
|
let extra = 0
|
||||||
|
|
||||||
while (body.endsWith("\\") && i + extra + 1 < rawLines.length) {
|
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]!
|
const next = rawLines[i + extra + 1]!
|
||||||
if (next.startsWith("$") || next.startsWith("@")) {
|
if (next.startsWith("$") || next.startsWith("@") || next.trim() === "" || next.startsWith("#")) {
|
||||||
throw new Error(`${path}:${i + extra + 2}: @def continuation consumed a command or directive line`)
|
throw new Error(`${path}:${i + extra + 2}: @def continuation consumed a command or directive line`)
|
||||||
}
|
}
|
||||||
body = body.slice(0, -1).trimEnd() + "\n" + next.trim()
|
body = body.slice(0, -1).trimEnd() + "\n" + next.trim()
|
||||||
extra++
|
extra++
|
||||||
}
|
}
|
||||||
|
|
||||||
body = body.trim()
|
|
||||||
if (!body) {
|
|
||||||
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name, body, linesConsumed: extra }
|
return { name, body, linesConsumed: extra }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user