188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
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 }
|
|
}
|