368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import * as T from "../types";
|
|
import { DocumentRange } from "../../types";
|
|
import { parseIngredientItem, parseMethodStep } from "./core";
|
|
import { ParseError } from "../../worker-errors";
|
|
import { isSyntaxError, SyntaxError } from "../constants";
|
|
|
|
/**
|
|
* We parse a Chef program by creating an array containing the lines of code (with line nos)
|
|
* in reverse order. This array represents a stack with the first line at the top.
|
|
*
|
|
* Each parse step then pops and parses statements from the stack. For instance,
|
|
* the parseTitle function pops one statement from the stack and parses it for the title.
|
|
* The rest of the stack is then parsed by firther steps.
|
|
*/
|
|
|
|
/** Reversed array of lines of code, used as a stack */
|
|
type CodeStack = { line: string; row: number }[];
|
|
|
|
/** Text and location of a single method instruction */
|
|
type MethodSegment = { str: string; location: DocumentRange };
|
|
|
|
/** Parse a Chef program */
|
|
export const parseProgram = (code: string): T.ChefProgram => {
|
|
// Convert code to a reverse stack
|
|
const stack: CodeStack = code
|
|
.split("\n")
|
|
.map((l, idx) => ({ line: l, row: idx }))
|
|
.reverse();
|
|
|
|
// Location of code's last char, used for errors
|
|
const lastCharPosition = stack[0]?.line.length - 1 || 0;
|
|
const lastCharRange: DocumentRange = {
|
|
startLine: stack.length - 1,
|
|
startCol: lastCharPosition,
|
|
endCol: lastCharPosition + 1,
|
|
};
|
|
|
|
// Exhaust any empty lines at the start of the program
|
|
exhaustEmptyLines(stack);
|
|
|
|
// Parse the main recipe
|
|
const mainRecipe = parseRecipe(stack, lastCharRange);
|
|
exhaustEmptyLines(stack);
|
|
|
|
// Parse any auxiliary recipes
|
|
const auxRecipes: { [k: string]: T.ChefRecipe } = {};
|
|
while (stack.length && stack[stack.length - 1].line.trim() !== "") {
|
|
const recipe = parseRecipe(stack, lastCharRange);
|
|
auxRecipes[recipe.name] = recipe;
|
|
exhaustEmptyLines(stack);
|
|
}
|
|
|
|
const program = { main: mainRecipe, auxes: auxRecipes };
|
|
validateProgram(program);
|
|
return program;
|
|
};
|
|
|
|
/** Pop all empty lines from top of the stack */
|
|
const exhaustEmptyLines = (stack: CodeStack): void => {
|
|
while (stack.length && stack[stack.length - 1].line.trim() === "")
|
|
stack.pop();
|
|
};
|
|
|
|
/**
|
|
* Parse the stack for recipe title
|
|
* @param stack Code stack to parse next line of
|
|
* @param lastCharRange Error range used if stack is found to be empty
|
|
*/
|
|
const parseTitle = (stack: CodeStack, lastCharRange: DocumentRange): string => {
|
|
const { line, row } = popCodeStack(stack, true);
|
|
if (line === null)
|
|
throw new ParseError("Expected recipe title", lastCharRange);
|
|
if (!line) throw new ParseError("Expected recipe title", { startLine: row });
|
|
if (!line.endsWith("."))
|
|
throw new ParseError("Recipe title must end with period", {
|
|
startLine: row,
|
|
});
|
|
return line.slice(0, -1);
|
|
};
|
|
|
|
/**
|
|
* Parse the stack for an empty line
|
|
* @param stack Code stack to parse next line of
|
|
* @param lastCharRange Error range used if stack is found to be empty
|
|
*/
|
|
const parseEmptyLine = (
|
|
stack: CodeStack,
|
|
lastCharRange: DocumentRange
|
|
): void => {
|
|
const { line, row } = popCodeStack(stack, true);
|
|
if (line === null) throw new ParseError("Expected blank line", lastCharRange);
|
|
if (line) throw new ParseError("Expected blank line", { startLine: row });
|
|
};
|
|
|
|
/** Parse the stack for method instructions section */
|
|
const parseRecipeComments = (stack: CodeStack): void => {
|
|
while (stack[stack.length - 1]?.line.trim() !== "") stack.pop();
|
|
};
|
|
|
|
/** Parse the stack for the header of ingredients section */
|
|
const parseIngredientsHeader = (stack: CodeStack): void => {
|
|
const { line, row } = popCodeStack(stack, true);
|
|
if (line !== "Ingredients.")
|
|
throw new ParseError("Expected ingredients header", { startLine: row });
|
|
};
|
|
|
|
/** Parse the stack for ingredient definition lines */
|
|
const parseIngredientsSection = (stack: CodeStack): T.IngredientBox => {
|
|
const box: T.IngredientBox = {};
|
|
while (stack[stack.length - 1].line.trim() !== "") {
|
|
const { line, row } = popCodeStack(stack);
|
|
const { name, item } = parseIngredientItem(line!);
|
|
box[name] = item;
|
|
}
|
|
return box;
|
|
};
|
|
|
|
/** Parse stack for cooking time statement. No data is returned. */
|
|
const parseCookingTime = (stack: CodeStack): void => {
|
|
const regex = /^Cooking time: \d+ (?:hours?|minutes?).$/;
|
|
const { line, row } = popCodeStack(stack, true);
|
|
if (!line!.match(regex))
|
|
throw new ParseError("Invalid cooking time statement", { startLine: row });
|
|
};
|
|
|
|
/** Parse stack for oven setting statement. No data is returned. */
|
|
const parseOvenSetting = (stack: CodeStack): void => {
|
|
const regex =
|
|
/^Pre-heat oven to \d+ degrees Celsius(?: \(gas mark [\d/]+\))?.$/;
|
|
const { line, row } = popCodeStack(stack, true);
|
|
if (!line!.match(regex))
|
|
throw new ParseError("Invalid oven setting statement", { startLine: row });
|
|
};
|
|
|
|
/** Parse the stack for the header of method section */
|
|
const parseMethodHeader = (stack: CodeStack): void => {
|
|
const { line, row } = popCodeStack(stack, true);
|
|
if (line !== "Method.")
|
|
throw new ParseError('Expected "Method."', { startLine: row });
|
|
};
|
|
|
|
/** Parse the stack for method instructions section */
|
|
const parseMethodSection = (stack: CodeStack): T.ChefOpWithLocation[] => {
|
|
const loopStack: { opener: number; verb: string }[] = [];
|
|
const pendingBreaks: number[] = [];
|
|
const segments = serializeMethodOps(stack);
|
|
const ops: T.ChefOpWithLocation[] = [];
|
|
|
|
segments.forEach((segment, index) => {
|
|
try {
|
|
processMethodSegment(segment, index, ops, loopStack, pendingBreaks);
|
|
} catch (error) {
|
|
if (isSyntaxError(error))
|
|
throw new ParseError(error.message, segment.location);
|
|
else throw error;
|
|
}
|
|
});
|
|
|
|
return ops;
|
|
};
|
|
|
|
/**
|
|
* Process a single method segment
|
|
* @param segment Method segment to process
|
|
* @param index Index of segment in the method section
|
|
* @param ops List of already processed method segments
|
|
* @param loopStack Stack of currently active loops
|
|
* @param pendingBreaks Loop-breaks in the currently active loop
|
|
*/
|
|
const processMethodSegment = (
|
|
segment: MethodSegment,
|
|
index: number,
|
|
ops: T.ChefOpWithLocation[],
|
|
loopStack: { opener: number; verb: string }[],
|
|
pendingBreaks: number[]
|
|
) => {
|
|
// Parse operation and push to result
|
|
const op = parseMethodStep(segment.str);
|
|
ops.push({ op, location: segment.location });
|
|
|
|
switch (op.code) {
|
|
case "LOOP-OPEN": {
|
|
loopStack.push({ opener: index, verb: op.verb });
|
|
// `closer` will be added while handling loop-closer
|
|
break;
|
|
}
|
|
|
|
case "LOOP-BREAK": {
|
|
pendingBreaks.push(index);
|
|
// `closer` will be added while handling loop-closer
|
|
break;
|
|
}
|
|
|
|
case "LOOP-CLOSE": {
|
|
// Validate match with innermost loop
|
|
const loop = loopStack.pop()!;
|
|
if (loop.verb !== op.verb)
|
|
throw new SyntaxError(
|
|
`Loop verb mismatch: expected '${loop.verb}', found '${op.verb}'`
|
|
);
|
|
|
|
op.opener = loop.opener;
|
|
|
|
// Add jump address to loop opener
|
|
const openerOp = ops[loop.opener].op;
|
|
if (openerOp.code !== "LOOP-OPEN") throw new Error("Bad jump address");
|
|
openerOp.closer = index;
|
|
|
|
// Add jump address to intermediate loop-breaks
|
|
while (pendingBreaks.length) {
|
|
const breaker = ops[pendingBreaks.pop()!].op;
|
|
if (breaker.code !== "LOOP-BREAK")
|
|
throw new Error("Memorized op not a breaker");
|
|
breaker.closer = index;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
/** Parse a method section and serialize to list of instructions and corresponding document ranges */
|
|
const serializeMethodOps = (stack: CodeStack): MethodSegment[] => {
|
|
const segments: MethodSegment[] = [];
|
|
|
|
while (stack.length && stack[stack.length - 1].line.trim() !== "") {
|
|
const item = stack.pop()!;
|
|
|
|
// Find all the periods in the line
|
|
const periodIdxs: number[] = [-1];
|
|
for (let i = 0; i < item.line.length; ++i) {
|
|
if (item.line[i] === ".") periodIdxs.push(i);
|
|
}
|
|
|
|
// Parse each period-separated segment
|
|
for (let i = 0; i < periodIdxs.length - 1; ++i) {
|
|
const start = periodIdxs[i] + 1;
|
|
const end = periodIdxs[i + 1];
|
|
const range = { startLine: item.row, startCol: start, endCol: end };
|
|
segments.push({
|
|
str: item.line.slice(start, end).trim(),
|
|
location: range,
|
|
});
|
|
}
|
|
}
|
|
|
|
return segments;
|
|
};
|
|
|
|
/** Parse the stack for a "Serves N" statement */
|
|
const parseServesLine = (stack: CodeStack): T.ChefRecipeServes => {
|
|
const { line, row } = popCodeStack(stack, true);
|
|
const match = line!.match(/^Serves (\d+).$/);
|
|
if (!match)
|
|
throw new ParseError("Malformed serves statement", { startLine: row });
|
|
return { line: row, num: parseInt(match[1], 10) };
|
|
};
|
|
|
|
/** Parse the stack for a single Chef recipe */
|
|
const parseRecipe = (
|
|
stack: CodeStack,
|
|
lastCharRange: DocumentRange
|
|
): T.ChefRecipe => {
|
|
// Title of the recipe
|
|
const title = parseTitle(stack, lastCharRange);
|
|
parseEmptyLine(stack, lastCharRange);
|
|
|
|
// Check if exists and parse recipe comments
|
|
assertCodeStackNotEmpty(stack, lastCharRange);
|
|
if (stack[stack.length - 1].line.trim() !== "Ingredients.") {
|
|
parseRecipeComments(stack);
|
|
parseEmptyLine(stack, lastCharRange);
|
|
}
|
|
|
|
// Parse ingredients
|
|
parseIngredientsHeader(stack);
|
|
const ingredientBox = parseIngredientsSection(stack);
|
|
parseEmptyLine(stack, lastCharRange);
|
|
|
|
// Check if exists and parse cooking time
|
|
assertCodeStackNotEmpty(stack, lastCharRange);
|
|
if (stack[stack.length - 1].line.trim().startsWith("Cooking time: ")) {
|
|
parseCookingTime(stack);
|
|
parseEmptyLine(stack, lastCharRange);
|
|
}
|
|
|
|
// Check if exists and parse oven temperature
|
|
assertCodeStackNotEmpty(stack, lastCharRange);
|
|
if (stack[stack.length - 1].line.trim().startsWith("Pre-heat oven ")) {
|
|
parseOvenSetting(stack);
|
|
parseEmptyLine(stack, lastCharRange);
|
|
}
|
|
|
|
// Parse method
|
|
parseMethodHeader(stack);
|
|
const method = parseMethodSection(stack);
|
|
exhaustEmptyLines(stack);
|
|
|
|
// Check if exists and parse recipe
|
|
const serves = stack[stack.length - 1]?.line.trim().startsWith("Serves ")
|
|
? parseServesLine(stack)
|
|
: undefined;
|
|
|
|
return { name: title, ingredients: ingredientBox, method, serves };
|
|
};
|
|
|
|
/**
|
|
* Validate the provided recipe.
|
|
* - Check that ingredient names used in method are valid.
|
|
* - Check that auxiliary recipe names used ar valid.
|
|
* @param recipe Recipe to validate
|
|
* @param auxes Map of auxiliary recipes, keyed by name
|
|
*/
|
|
const validateRecipe = (
|
|
recipe: T.ChefRecipe,
|
|
auxes: T.ChefProgram["auxes"]
|
|
): void => {
|
|
for (const line of recipe.method) {
|
|
const ingName = (line.op as any).ing;
|
|
if (ingName && !recipe.ingredients[ingName])
|
|
throw new ParseError(`Invalid ingredient: ${ingName}`, line.location);
|
|
if (line.op.code === "FNCALL" && !auxes[line.op.recipe])
|
|
throw new ParseError(
|
|
`Invalid recipe name: ${line.op.recipe}`,
|
|
line.location
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate all recipes in the given parsed Chef program
|
|
* @param program Chef program to validate
|
|
*/
|
|
const validateProgram = (program: T.ChefProgram): void => {
|
|
validateRecipe(program.main, program.auxes);
|
|
for (const auxName in program.auxes)
|
|
validateRecipe(program.auxes[auxName], program.auxes);
|
|
};
|
|
|
|
/**
|
|
* Utility to pop a line off the code stack.
|
|
* @param stack Code stack to pop line off
|
|
* @param trim Pass true if result should contain trimmed line
|
|
* @returns Object containing `line` and `row` of popped line
|
|
*/
|
|
const popCodeStack = (
|
|
stack: CodeStack,
|
|
trim?: boolean
|
|
): { line: string | null; row: number } => {
|
|
const item = stack.pop();
|
|
if (!item) return { line: null, row: -1 };
|
|
const line = trim ? item.line.trim() : item.line;
|
|
return { line, row: item.row };
|
|
};
|
|
|
|
/**
|
|
* Utility to assert that given code stack is not empty.
|
|
* If it is, throws a ParseError for unexpected EOF for the given DocumentRange.
|
|
* @param stack Code stack to assert on
|
|
* @param range DocumentRange to throw ParseError on
|
|
*/
|
|
const assertCodeStackNotEmpty = (
|
|
stack: CodeStack,
|
|
range: DocumentRange
|
|
): void => {
|
|
if (stack.length == 0) throw new ParseError("Unexpected EOF", range);
|
|
};
|