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 } 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) } 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 rest = line.slice(5).trim() const eq = rest.indexOf("=") if (eq <= 0) { throw new Error(`${path}:${i + 1}: malformed @env directive (expected KEY=VALUE): ${line}`) } directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 }) } else { throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) } continue } 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 } }