/** * @factory/database-engine/formula — Pratt parser (TASK-0084 R2). * * Precedence (low→high): or < and < comparison < additive < multiplicative * < unary < ternary is lowest (right-assoc). Comparison is left-associative * (chained `a < b < c` allowed, documented). `cond ? a : b` parses to the * SAME `If` node as `if(cond, a, b)` — syntactic sugar, recorded. Max * nesting depth 16 (PARSE beyond). Unknown function names are rejected at * parse time (UNKNOWN_FUNCTION) against the closed function table — never * looked up in globalThis (the security half of the zero-eval gate). */ import { tokenize, type Token } from "./lexer.js"; import { FormulaError, type AstNode, MAX_NESTING } from "./types.js"; import { FUNCTION_NAMES } from "./functions.js"; class Parser { private pos = 0; private depth = 0; private readonly tokens: Token[]; constructor(tokens: Token[]) { this.tokens = tokens; } private peek(): Token { return this.tokens[this.pos]!; } private next(): Token { const t = this.tokens[this.pos]!; this.pos++; return t; } private isOp(value: string): boolean { const t = this.peek(); return t.kind === "op" && t.value === value; } private eat(value: string): void { const t = this.peek(); if (t.kind !== "op" || t.value !== value) throw new FormulaError("PARSE", `expected "${value}"`, t.pos); this.next(); } parse(): AstNode { const node = this.parseTernary(0); const t = this.peek(); if (t.kind !== "eof") throw new FormulaError("PARSE", `unexpected token at position ${t.pos}`, t.pos); return node; } private checkDepth(): void { this.depth++; if (this.depth > MAX_NESTING) throw new FormulaError("PARSE", `nesting depth exceeds ${MAX_NESTING}`, this.peek().pos); } /** Pratt: parseExpr(minBp). */ private parseExpr(minBp: number): AstNode { let left = this.parsePrefix(); for (;;) { const t = this.peek(); if (t.kind !== "op") break; const bp = BINARY_PRECEDENCE[t.value]; if (bp === undefined || bp[0] < minBp) break; this.next(); const right = this.parseExpr(bp[1]); left = { type: "BinaryOp", op: t.value, left, right }; } return left; } private parsePrefix(): AstNode { const t = this.next(); if (t.kind === "number") return { type: "Literal", value: t.value, literalType: "number" }; if (t.kind === "string") return { type: "Literal", value: t.value, literalType: "string" }; if (t.kind === "date") return { type: "Literal", value: t.value, literalType: "date" }; if (t.kind === "bool") return { type: "Literal", value: t.value, literalType: "bool" }; if (t.kind === "null") return { type: "NullLiteral" }; if (t.kind === "op" && t.value === "[") { this.checkDepth(); const elements: AstNode[] = []; if (!this.isOp("]")) { for (;;) { elements.push(this.parseTernary(0)); if (this.isOp(",")) { this.next(); continue; } break; } } this.depth--; this.eat("]"); return { type: "ArrayLiteral", elements }; } if (t.kind === "op" && t.value === "-") { this.checkDepth(); const operand = this.parseExpr(UNARY_BP); return { type: "UnaryOp", op: "-", operand }; } if (t.kind === "op" && t.value === "not") { this.checkDepth(); const operand = this.parseExpr(UNARY_BP); return { type: "UnaryOp", op: "not", operand }; } if (t.kind === "op" && t.value === "(") { this.checkDepth(); const inner = this.parseTernary(0); this.depth--; this.eat(")"); return inner; } if (t.kind === "ident") { // `if(c, a, b)` parses to the SAME If node as `c ? a : b` (spec R2) if (t.value === "if" && this.isOp("(")) { this.next(); this.checkDepth(); const cond = this.parseTernary(0); this.eat(","); const then = this.parseTernary(0); this.eat(","); const elseBranch = this.parseTernary(0); this.depth--; this.eat(")"); return { type: "If", cond, then, else: elseBranch }; } // function call or bare identifier (bare ident = error: use prop()) if (this.isOp("(")) { this.next(); this.checkDepth(); const args: AstNode[] = []; if (!this.isOp(")")) { for (;;) { args.push(this.parseTernary(0)); if (this.isOp(",")) { this.next(); continue; } break; } } this.depth--; this.eat(")"); if (!FUNCTION_NAMES.has(t.value)) { throw new FormulaError("UNKNOWN_FUNCTION", `unknown function "${t.value}"`, t.pos); } return { type: "Call", name: t.value, args }; } throw new FormulaError( "PARSE", `unexpected identifier "${t.value}" (use prop("${t.value}"))`, t.pos, ); } throw new FormulaError("PARSE", "unexpected token", t.pos); } /** Ternary: expr ?: cond ? a : b (right-assoc, lowest precedence). */ private parseTernary(minBp: number): AstNode { const cond = this.parseExpr(minBp); if (this.isOp("?")) { this.next(); const then = this.parseTernary(0); this.eat(":"); const elseBranch = this.parseTernary(TERNARY_RBP); this.checkDepth(); return { type: "If", cond, then, else: elseBranch }; } return cond; } } // precedence table: [left-binding, right-binding] const UNARY_BP = 70; const TERNARY_RBP = 10; const BINARY_PRECEDENCE: Record = { or: [10, 11], and: [20, 21], "==": [30, 31], "!=": [30, 31], "<": [30, 31], "<=": [30, 31], ">": [30, 31], ">=": [30, 31], "+": [40, 41], "-": [40, 41], "*": [50, 51], "/": [50, 51], "%": [50, 51], }; export function parseAst(tokens: Token[]): AstNode { return new Parser(tokens).parse(); } export function parseFormula(src: string): AstNode { return parseAst(tokenize(src)); }