- init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user