diff --git a/CLAUDE.md b/CLAUDE.md index ba665c7..fe92f7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ 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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 9f2702e..b7da8bb 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -117,7 +117,8 @@ program const envVars: Record = {} const setupEnvVars: Record = {} const userEnvVars: Record = {} - const macros: Record = {} + const setupMacros: Record = {} + const userMacros: Record = {} const setupCommands: Command[] = [] const teardownCommands: Command[] = [...parsed.teardownCommands] for (const d of parsed.directives) { @@ -127,31 +128,28 @@ 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") macros[sd.name] = sd.body + 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") { - macros[d.name] = d.body + userMacros[d.name] = d.body } } Object.assign(envVars, setupEnvVars, userEnvVars) if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) { envVars["PORT"] = String(port) } + const macros: Record = 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.map(expandMacro), - ...parsed.commands.map(expandMacro), - ...teardownCommands.map(expandMacro), - ], + commands: [...setupCommands, ...parsed.commands, ...teardownCommands].map(expandMacro), } const setupLen = setupCommands.length diff --git a/src/parse.test.ts b/src/parse.test.ts index f7e3c96..8c59714 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -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", () => { const result = parse("test.shout", "$ cat file\n@def foo bar\n") expect(result.directives).toEqual([]) diff --git a/src/parse.ts b/src/parse.ts index 35cda7b..28a392d 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -77,23 +77,24 @@ function parseDefDirective( throw new Error(`${path}:${i + 1}: @def requires a name and body`) } 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 - 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]! - 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`) } body = body.slice(0, -1).trimEnd() + "\n" + next.trim() extra++ } - body = body.trim() - if (!body) { - throw new Error(`${path}:${i + 1}: @def requires a name and body`) - } - return { name, body, linesConsumed: extra } }