export type Command = { line: number raw: string command: string expected: string[] exitCode: number | "*" | null } export type Directive = | { type: "setup"; path: string; line: number } | { type: "env"; key: string; value: string; line: number } export type ShoutFile = { path: string commands: Command[] directives: Directive[] } function stripComment(line: string): string { // Strip trailing # comment from command line // Be careful not to strip # inside quotes let inSingle = false let inDouble = false for (let i = 0; i < line.length; i++) { const ch = line[i] if (ch === "'" && !inDouble) inSingle = !inSingle else if (ch === '"' && !inSingle) inDouble = !inDouble else if (ch === "#" && !inSingle && !inDouble) { return line.slice(0, i).trimEnd() } } return line } /** A line like "$# ..." or "$ # ..." — a comment, not a real command */ export function isCommentLine(line: string): boolean { return line.startsWith("$#") || (line.startsWith("$ ") && stripComment(line.slice(2)) === "") } function parseExitCode(lines: string[]): { lines: string[] exitCode: number | "*" | null } { if (lines.length === 0) return { lines, exitCode: null } const last = lines[lines.length - 1]! const match = last.match(/^\[(\d+|\*)\]$/) if (match) { const code = match[1] === "*" ? "*" as const : parseInt(match[1]!, 10) return { lines: lines.slice(0, -1), exitCode: code } } return { lines, exitCode: null } } function trimTrailingEmpty(lines: string[]): string[] { let end = lines.length while (end > 0 && lines[end - 1] === "") end-- return lines.slice(0, end) } function parseEnvDirective(path: string, line: string, lineNum: number): { key: string; value: string } { const rest = line.slice(5).trim() const eq = rest.indexOf("=") if (eq <= 0) { throw new Error(`${path}:${lineNum}: malformed @env directive (expected KEY=VALUE): ${line}`) } return { key: rest.slice(0, eq), value: rest.slice(eq + 1) } } export function parseSetup(path: string, content: string): ShoutFile { const rawLines = content.split("\n") if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") { rawLines.pop() } const commands: Command[] = [] const directives: Directive[] = [] for (let i = 0; i < rawLines.length; i++) { const line = rawLines[i]! if (line === "" || line.startsWith("#")) continue if (line.startsWith("@")) { 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("@setup ")) { throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`) } else { throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) } continue } commands.push({ line: i + 1, raw: line, command: stripComment(line), expected: [], exitCode: null, }) } return { path, commands, directives } } export function parse(path: string, content: string): ShoutFile { const rawLines = content.split("\n") // Remove trailing newline (spec: "Trailing newline on the file is ignored") if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") { rawLines.pop() } const commands: Command[] = [] const directives: Directive[] = [] let current: Command | null = null let seenCommand = false for (let i = 0; i < rawLines.length; i++) { const line = rawLines[i]! if (!seenCommand && line.startsWith("@")) { if (line.startsWith("@setup ")) { const setupPath = line.slice(7).trim() if (!setupPath) { throw new Error(`${path}:${i + 1}: @setup requires a file path`) } directives.push({ type: "setup", path: setupPath, line: i + 1 }) } else if (line.startsWith("@env ")) { const { key, value } = parseEnvDirective(path, line, i + 1) directives.push({ type: "env", key, value, line: i + 1 }) } else { throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) } continue } if (isCommentLine(line)) { // Comment line like "$# ..." or "$ # ..." — finalize current and skip seenCommand = true if (current) { const trimmed = trimTrailingEmpty(current.expected) const { lines: expectedLines, exitCode } = parseExitCode(trimmed) current.expected = trimTrailingEmpty(expectedLines) current.exitCode = exitCode commands.push(current) } current = null } else if (line.startsWith("\\$ ") && current) { // Escaped dollar-space: literal expected output starting with "$ " current.expected.push(line.slice(1)) } else if (line.startsWith("$ ")) { seenCommand = true if (current) { const trimmed = trimTrailingEmpty(current.expected) const { lines: expectedLines, exitCode } = parseExitCode(trimmed) current.expected = trimTrailingEmpty(expectedLines) current.exitCode = exitCode commands.push(current) } current = { line: i + 1, raw: line, command: stripComment(line.slice(2)), expected: [], exitCode: null, } } else if (current) { current.expected.push(line) } } if (current) { const trimmed = trimTrailingEmpty(current.expected) const { lines: expectedLines, exitCode } = parseExitCode(trimmed) current.expected = trimTrailingEmpty(expectedLines) current.exitCode = exitCode commands.push(current) } return { path, commands, directives } }