1import { RenderError } from '../runtime/errors'23export interface TrnIngredient {4 id: string5 label: string6 line: number7}89export interface TrnInput {10 id: string11 quantity: string12 line: number13}1415export interface TrnStage {16 inputs: TrnInput[]17 action: string18 line: number19}2021export interface TrnOutcome {22 id: string23 label: string24 portion: string25 stages: TrnStage[]26 line: number27}2829export type TrnLayout = 'combined' | 'individual'3031export interface TrnDocument {32 layout: TrnLayout33 ingredients: Map<string, TrnIngredient>34 outcomes: Map<string, TrnOutcome>35 instructions: Array<{ text: string; line: number }>36 roots: TrnOutcome[]37}3839const IDENTIFIER = '[A-Za-z][A-Za-z0-9_]*'4041function sourceError(line: number, message: string): never {42 throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)43}4445function displayName(id: string): string {46 return id.replaceAll('_', ' ').replace(/\b\w/g, character => character.toUpperCase())47}4849function quotedLabel(value: string, line: number): string {50 try {51 return JSON.parse(`"${value}"`) as string52 } catch {53 return sourceError(line, 'The display label is not a valid quoted string.')54 }55}5657function validateReferences(document: TrnDocument): void {58 const referencedOutcomes = new Set<string>()59 for (const outcome of document.outcomes.values()) {60 for (const stage of outcome.stages) {61 for (const input of stage.inputs) {62 if (document.outcomes.has(input.id)) referencedOutcomes.add(input.id)63 else if (!document.ingredients.has(input.id)) sourceError(input.line, `Unknown value '${input.id}'.`)64 }65 }66 }6768 const visiting = new Set<string>()69 const visited = new Set<string>()70 function visit(id: string, path: string[]): void {71 if (visiting.has(id)) sourceError(document.outcomes.get(id)!.line, `Outcome cycle: ${[...path, id].join(' -> ')}.`)72 if (visited.has(id)) return73 visiting.add(id)74 const outcome = document.outcomes.get(id)!75 for (const stage of outcome.stages) {76 for (const input of stage.inputs) {77 if (document.outcomes.has(input.id)) visit(input.id, [...path, id])78 }79 }80 visiting.delete(id)81 visited.add(id)82 }83 for (const id of document.outcomes.keys()) visit(id, [])8485 document.roots = [...document.outcomes.values()].filter(outcome => !referencedOutcomes.has(outcome.id))86 if (document.roots.length === 0) sourceError(1, 'The document has no unconsumed outcome to render.')87}8889export function parseTrn(source: string): TrnDocument {90 const document: TrnDocument = {91 layout: 'combined',92 ingredients: new Map(),93 outcomes: new Map(),94 instructions: [],95 roots: [],96 }97 let current: { id: string; label: string; portion: string; line: number; stages: TrnStage[]; pending: TrnInput[] } | null = null98 let layoutDeclared = false99 let declarationsStarted = false100101 const lines = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n')102 for (let index = 0; index < lines.length; index += 1) {103 const lineNumber = index + 1104 const line = lines[index]!.trim()105 if (line === '' || line.startsWith('#')) continue106107 if (current !== null) {108 if (line === '}') {109 if (current.pending.length > 0) sourceError(lineNumber, `Outcome '${current.id}' has inputs without a following step.`)110 if (current.stages.length === 0) sourceError(current.line, `Outcome '${current.id}' must contain at least one step.`)111 const portionStage = current.stages.find(stage => stage.action.includes('{portion}'))112 if (portionStage !== undefined && current.portion === '') {113 sourceError(portionStage.line, `Action in '${current.id}' uses {portion}, but the outcome has no portion.`)114 }115 const outcome: TrnOutcome = {116 id: current.id,117 label: current.label,118 portion: current.portion,119 stages: current.stages,120 line: current.line,121 }122 document.outcomes.set(outcome.id, outcome)123 current = null124 continue125 }126127 const inputMatch = line.match(new RegExp(`^\\+\\s+(${IDENTIFIER})(?:\\s+(.+))?$`))128 if (inputMatch !== null) {129 current.pending.push({ id: inputMatch[1]!, quantity: inputMatch[2]?.trim() ?? '', line: lineNumber })130 continue131 }132 const stepMatch = line.match(/^->\s+(.+)$/)133 if (stepMatch !== null) {134 if (current.stages.length === 0 && current.pending.length === 0) {135 sourceError(lineNumber, `The first step in '${current.id}' needs an input.`)136 }137 current.stages.push({ inputs: current.pending, action: stepMatch[1]!.trim(), line: lineNumber })138 current.pending = []139 continue140 }141 sourceError(lineNumber, "Expected '+ value', '-> action', or '}'.")142 }143144 const layoutMatch = line.match(/^\.layout\s+(combined|individual)\s*$/)145 if (layoutMatch !== null) {146 if (declarationsStarted) sourceError(lineNumber, 'The .layout directive must appear before recipe declarations.')147 if (layoutDeclared) sourceError(lineNumber, 'The .layout directive may only appear once.')148 document.layout = layoutMatch[1] as TrnLayout149 layoutDeclared = true150 continue151 }152 if (line.startsWith('.layout')) sourceError(lineNumber, "Expected '.layout combined' or '.layout individual'.")153154 const instructionMatch = line.match(/^instruction\s+"((?:[^"\\]|\\.)*)"\s*$/)155 if (instructionMatch !== null) {156 declarationsStarted = true157 document.instructions.push({ text: quotedLabel(instructionMatch[1]!, lineNumber), line: lineNumber })158 continue159 }160161 const ingredientMatch = line.match(new RegExp(`^ingredient\\s+(${IDENTIFIER})(?:\\s+"((?:[^"\\\\]|\\\\.)*)")?\\s*$`))162 if (ingredientMatch !== null) {163 declarationsStarted = true164 const id = ingredientMatch[1]!165 if (document.ingredients.has(id) || document.outcomes.has(id)) sourceError(lineNumber, `Duplicate value '${id}'.`)166 document.ingredients.set(id, {167 id,168 label: ingredientMatch[2] === undefined ? displayName(id) : quotedLabel(ingredientMatch[2], lineNumber),169 line: lineNumber,170 })171 continue172 }173174 const outcomeMatch = line.match(new RegExp(`^outcome\\s+(${IDENTIFIER})(?:\\s+"((?:[^"\\\\]|\\\\.)*)")?(?:\\s+portion\\s+(.+?))?\\s*\\{\\s*$`))175 if (outcomeMatch !== null) {176 declarationsStarted = true177 const id = outcomeMatch[1]!178 if (document.ingredients.has(id) || document.outcomes.has(id)) sourceError(lineNumber, `Duplicate value '${id}'.`)179 current = {180 id,181 label: outcomeMatch[2] === undefined ? displayName(id) : quotedLabel(outcomeMatch[2], lineNumber),182 portion: outcomeMatch[3]?.trim() ?? '',183 line: lineNumber,184 stages: [],185 pending: [],186 }187 continue188 }189190 sourceError(lineNumber, "Expected an instruction, ingredient, or outcome declaration.")191 }192193 if (current !== null) sourceError(current.line, `Outcome '${current.id}' is missing its closing brace.`)194 if (document.outcomes.size === 0) sourceError(1, 'The document must declare at least one outcome.')195 validateReferences(document)196 return document197}