125 lines
3.5 KiB
TypeScript
125 lines
3.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
|
|
}
|
|
|
|
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 }
|
|
}
|