This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
/**
* 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<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
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;
}