285 lines
8.1 KiB
TypeScript
285 lines
8.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|