/** * Recursive-descent parser: token stream -> AST. * * --------------------------------------------------------------------------- * GRAMMAR (EBNF) * --------------------------------------------------------------------------- * formula = "=" expression ; * * expression = term { ("+" | "-") term } ; (* left assoc *) * term = unary { ("*" | "/") unary } ; (* left assoc *) * unary = ("+" | "-") unary * | power ; * power = primary [ "^" unary ] ; (* right assoc, and * binds tighter than * unary minus so that * -2^2 = -(2^2) = -4 *) * primary = NUMBER * | STRING * | "(" expression ")" * | funcall * | reference ; * * funcall = IDENT "(" [ expression { ";" expression } ] ")" ; * * reference = [ qualifier ] CELL [ ":" CELL ] ; * qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the * Numbers-style "::" are * interchangeable *) * CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; * * Precedence, lowest to highest: + - < * / < unary +/- < ^ * --------------------------------------------------------------------------- */ import { tokenize, type Token, type TokenType, LexError } from './lexer'; import { looksLikeCell, parseA1 } from './refs'; export type Node = | NumberNode | StringNode | BinaryNode | UnaryNode | FuncNode | CellNode | RangeNode; export interface NumberNode { kind: 'number'; value: number; } export interface StringNode { kind: 'string'; value: string; } export interface BinaryNode { kind: 'binary'; op: '+' | '-' | '*' | '/' | '^'; left: Node; right: Node; } export interface UnaryNode { kind: 'unary'; op: '+' | '-'; operand: Node; } export interface FuncNode { kind: 'func'; name: string; args: Node[]; } /** Single cell reference, optionally qualified with a table name. */ export interface CellNode { kind: 'cell'; /** raw table name as written ("Umsatz"), or null for "this table" */ qualifier: string | null; col: number; row: number; } /** Rectangular range reference, optionally qualified with a table name. */ export interface RangeNode { kind: 'range'; qualifier: string | null; start: { col: number; row: number }; end: { col: number; row: number }; } export class ParseError extends Error {} class Parser { private i = 0; constructor(private readonly toks: Token[]) {} private peek(): Token { return this.toks[this.i]; } private next(): Token { return this.toks[this.i++]; } private at(t: TokenType): boolean { return this.toks[this.i].type === t; } private expect(t: TokenType): Token { if (!this.at(t)) { throw new ParseError(`Expected ${t} but got ${this.peek().type} at ${this.peek().pos}`); } return this.next(); } parse(): Node { const node = this.expression(); if (!this.at('EOF')) { throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`); } return node; } private expression(): Node { let left = this.term(); while (this.at('PLUS') || this.at('MINUS')) { const op = this.next().type === 'PLUS' ? '+' : '-'; left = { kind: 'binary', op, left, right: this.term() }; } return left; } private term(): Node { let left = this.unary(); while (this.at('STAR') || this.at('SLASH')) { const op = this.next().type === 'STAR' ? '*' : '/'; left = { kind: 'binary', op, left, right: this.unary() }; } return left; } private unary(): Node { if (this.at('PLUS') || this.at('MINUS')) { const op = this.next().type === 'PLUS' ? '+' : '-'; return { kind: 'unary', op, operand: this.unary() }; } return this.power(); } private power(): Node { const base = this.primary(); if (this.at('CARET')) { this.next(); // right operand is `unary` -> right-associative and allows -exponent return { kind: 'binary', op: '^', left: base, right: this.unary() }; } return base; } private primary(): Node { const t = this.peek(); if (t.type === 'NUMBER') { this.next(); return { kind: 'number', value: Number(t.value) }; } if (t.type === 'STRING') { this.next(); return { kind: 'string', value: t.value }; } if (t.type === 'LPAREN') { this.next(); const inner = this.expression(); this.expect('RPAREN'); return inner; } if (t.type === 'QUOTED') { // must be a table-qualified reference: 'Kosten Q3'!B2 or 'Kosten Q3'::B2 this.next(); this.expectQualifierSep(); return this.reference(t.value); } if (t.type === 'IDENT') { this.next(); // function call? if (this.at('LPAREN')) { this.next(); const args: Node[] = []; if (!this.at('RPAREN')) { args.push(this.expression()); while (this.at('SEMICOLON')) { this.next(); args.push(this.expression()); } } this.expect('RPAREN'); return { kind: 'func', name: t.value, args }; } // table-qualified reference? IDENT ("!" | "::") ... if (this.at('BANG') || this.at('DBLCOLON')) { this.next(); return this.reference(t.value); } // otherwise it must be a local cell reference return this.reference(null, t); } throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`); } /** * Parse the cell/range part of a reference. `firstTok` (when given) is an * IDENT already consumed by the caller for the *local* case. */ private reference(qualifier: string | null, firstTok?: Token): Node { const a = firstTok ?? this.expectCellIdent(); if (!firstTok && a.type !== 'IDENT') { throw new ParseError(`Expected cell reference at ${a.pos}`); } if (!looksLikeCell(a.value)) { throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`); } const start = parseA1(a.value); if (this.at('COLON')) { this.next(); const b = this.expectCellIdent(); if (!looksLikeCell(b.value)) { throw new ParseError(`'${b.value}' is not a valid cell reference (pos ${b.pos})`); } const end = parseA1(b.value); return { kind: 'range', qualifier, start, end }; } return { kind: 'cell', qualifier, col: start.col, row: start.row }; } private expectQualifierSep(): void { if (this.at('BANG') || this.at('DBLCOLON')) { this.next(); return; } throw new ParseError( `Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`, ); } private expectCellIdent(): Token { if (!this.at('IDENT')) { throw new ParseError(`Expected cell reference but got ${this.peek().type} at ${this.peek().pos}`); } return this.next(); } } /** * Parse a raw formula. The leading "=" is optional (callers usually strip it). * Throws {@link ParseError} / {@link LexError} on malformed input; the store * turns those into the `#ERR` cell value. */ export function parseFormula(src: string): Node { const body = src.startsWith('=') ? src.slice(1) : src; let toks: Token[]; try { toks = tokenize(body); } catch (e) { if (e instanceof LexError) throw new ParseError(e.message); throw e; } return new Parser(toks).parse(); } /** Walk an AST and collect every cell/range reference node it contains. */ export function collectRefs(node: Node, out: Array = []): Array { switch (node.kind) { case 'cell': case 'range': out.push(node); break; case 'binary': collectRefs(node.left, out); collectRefs(node.right, out); break; case 'unary': collectRefs(node.operand, out); break; case 'func': node.args.forEach((a) => collectRefs(a, out)); break; } return out; }