shout/src/parse.ts
Chris Wanstrath 24981c6cc0 Merge branch 'code-cleanup'
# Conflicts:
#	src/parse.ts
#	src/run.ts
#	src/update.ts
2026-03-13 14:08:37 -07:00

211 lines
6.3 KiB
TypeScript

import { trimTrailingEmpty } from "./utils.ts"
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[]
teardownCommands: Command[]
}
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 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 teardownCommands: 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("@teardown ")) {
const cmd = line.slice(10)
if (!cmd.trim()) {
throw new Error(`${path}:${i + 1}: @teardown requires a command`)
}
teardownCommands.push({
line: i + 1,
raw: line,
command: stripComment(cmd),
expected: [],
exitCode: null,
})
} 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, teardownCommands }
}
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 teardownCommands: 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("@teardown ")) {
const cmd = line.slice(10)
if (!cmd.trim()) {
throw new Error(`${path}:${i + 1}: @teardown requires a command`)
}
teardownCommands.push({
line: i + 1,
raw: line,
command: stripComment(cmd),
expected: [],
exitCode: null,
})
} 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, teardownCommands }
}