- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the formula functions with one search over everything. Topics and shortcuts are plain data in data/help.ts; the function list comes from codeReference.ts so new built-ins appear by themselves. - While a formula is edited, the grid outlines each referenced cell/range in the colour of its position in the text, and the outline can be dragged to move or resize the reference, as in Excel. Point-mode picks (click, drag, arrow keys) are outlined too, so keyboard picking is no longer blind. - Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on unless every selected cell already has the format. - A formula committed with open parentheses or an open text literal gets the missing closers appended; anything that still doesn't parse is left alone so the usual error shows instead of a guess. - Cells and tables with a fill but no text colour get dark or light text by WCAG contrast instead of the theme colour (no black on black).
928 lines
38 KiB
TypeScript
928 lines
38 KiB
TypeScript
/**
|
|
* Recursive-descent parser: token stream -> AST.
|
|
*
|
|
* ---------------------------------------------------------------------------
|
|
* GRAMMAR (EBNF)
|
|
* ---------------------------------------------------------------------------
|
|
* formula = "=" expression ;
|
|
*
|
|
* expression = comparison ;
|
|
* comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
|
|
* (* at most one comparison,
|
|
* not chainable like
|
|
* "a < b < c"; mainly
|
|
* useful in a "block"
|
|
* function body's IF
|
|
* condition, but valid
|
|
* in any formula *)
|
|
* concat = additive { "&" additive } ; (* left assoc; text
|
|
* concatenation, binds
|
|
* looser than "+ -" but
|
|
* tighter than a
|
|
* comparison -- same
|
|
* slot Excel gives "&" *)
|
|
* additive = 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
|
|
* | ERRORLIT
|
|
* | "(" expression ")"
|
|
* | funcall
|
|
* | reference ;
|
|
*
|
|
* funcall = IDENT "(" [ arg { ";" arg } ] ")" ;
|
|
* arg = expression | (* empty *) ; (* an empty slot
|
|
* between two ";"
|
|
* (or "(...;)" right
|
|
* before ")") is an
|
|
* OmittedNode -- see
|
|
* its doc comment;
|
|
* "skip this optional
|
|
* argument", e.g.
|
|
* XLOOKUP(x;a;b;;-1) *)
|
|
*
|
|
* reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
|
* qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the
|
|
* Numbers-style "::" are
|
|
* interchangeable *)
|
|
* sheetQualifier = (IDENT | QUOTED) "!" ; (* sheet name -- only
|
|
* recognised when it is
|
|
* immediately followed by
|
|
* a full "table::" --
|
|
* i.e. only in the 3-part
|
|
* form "Blatt!Tabelle::A1".
|
|
* A bare "X!Y" with no
|
|
* "::" after Y keeps its
|
|
* original meaning: X is
|
|
* the TABLE qualifier
|
|
* (backward-compatible --
|
|
* see reference() below). *)
|
|
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
|
* ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
|
|
* "#VALUE!" | "#N/A" ; (* a literal error code,
|
|
* typed directly or
|
|
* produced by a
|
|
* structural row/col
|
|
* delete *)
|
|
*
|
|
* Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^
|
|
* ---------------------------------------------------------------------------
|
|
*
|
|
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
|
|
* when parseFormula() is called with a `paramNames` list (used to parse a
|
|
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
|
* that list -- i.e. for every ordinary cell formula -- a bare, unqualified
|
|
* IDENT that doesn't look like a cell address is instead a *named-range*
|
|
* reference (`NamedRefNode`, see below): the parser has no opinion on
|
|
* whether the name actually exists (same as it never validates a table
|
|
* qualifier) -- an unresolvable one surfaces as `#NAME?` only at evaluation
|
|
* time (see evaluator.ts's `resolveNamedRef()`). A *qualified* non-cell
|
|
* identifier (`Tabelle!Foo`) still is a `ParseError` -- a named range is
|
|
* always unqualified, so a qualifier always means "this must be a real
|
|
* cell".
|
|
* ---------------------------------------------------------------------------
|
|
*/
|
|
|
|
import { tokenize, type Token, type TokenType, LexError } from './lexer';
|
|
import { looksLikeCell, parseA1, wholeLineAt, WHOLE_LINE_END } from './refs';
|
|
import type { ErrorCode } from './types';
|
|
|
|
export type Node =
|
|
| NumberNode
|
|
| StringNode
|
|
| ErrorLiteralNode
|
|
| BinaryNode
|
|
| UnaryNode
|
|
| FuncNode
|
|
| CellNode
|
|
| RangeNode
|
|
| NamedRefNode
|
|
| ParamNode
|
|
| BlockCallNode
|
|
| OmittedNode
|
|
| JsonLiteralNode;
|
|
|
|
export interface NumberNode {
|
|
kind: 'number';
|
|
value: number;
|
|
}
|
|
export interface StringNode {
|
|
kind: 'string';
|
|
value: string;
|
|
}
|
|
/** A literal error code typed directly into a formula, e.g. `=#REF!+1` or
|
|
* `=IF(A1=0;#N/A;A1)` -- same as typing one in Excel. Evaluates directly to
|
|
* that `FormulaError`, unconditionally (it's not the *result* of anything,
|
|
* just a value like a number or string literal). Also how a structural
|
|
* row/col delete represents a now-broken reference in stored formula text
|
|
* (see store/document.ts). */
|
|
export interface ErrorLiteralNode {
|
|
kind: 'errorliteral';
|
|
code: ErrorCode;
|
|
}
|
|
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;
|
|
/** raw sheet name as written ("Analyse") -- only set for the explicit
|
|
* "Blatt!Tabelle::Zelle" 3-part form; absent for every plain
|
|
* "Tabelle!Zelle" / "Tabelle::Zelle" reference (resolved without regard
|
|
* to sheet, same as before this existed). */
|
|
sheetQualifier?: string;
|
|
col: number;
|
|
row: number;
|
|
}
|
|
/** Rectangular range reference, optionally qualified with a table name. */
|
|
export interface RangeNode {
|
|
kind: 'range';
|
|
qualifier: string | null;
|
|
/** see {@link CellNode.sheetQualifier}. */
|
|
sheetQualifier?: string;
|
|
start: { col: number; row: number };
|
|
end: { col: number; row: number };
|
|
/** A whole-column (`B:B`, `A:C`) or whole-row (`1:1`, `2:5`) range: the
|
|
* other axis runs from 0 to `WHOLE_LINE_END` (refs.ts), clamped to the
|
|
* table's size wherever cells are read (`rangeCells`). */
|
|
whole?: 'col' | 'row';
|
|
}
|
|
/** A bare, unqualified name that isn't a param and doesn't look like a cell
|
|
* -- a candidate reference to a document-wide *named range* (`Umsatz_Q1`
|
|
* instead of `Umsatz!B2:B13`), created/edited/removed through the "🏷
|
|
* Namen" UI (see `src/store/types.ts`'s `NamedRange`). The parser doesn't
|
|
* know the document's named ranges (same as it never validates a table
|
|
* qualifier) -- resolved live, by name, at evaluation/dependency-collection
|
|
* time via `EngineHost.resolveNamedRange()`; an unknown name is `#NAME?`. */
|
|
export interface NamedRefNode {
|
|
kind: 'namedref';
|
|
name: string;
|
|
}
|
|
/** A reference to a user-defined function's own parameter -- only produced
|
|
* when parseFormula() is given a `paramNames` list (parsing a function
|
|
* body); never appears in an ordinary cell formula's AST. Resolved away by
|
|
* userFunctions.ts's expandUserFunctions() before evaluation ever sees it. */
|
|
export interface ParamNode {
|
|
kind: 'param';
|
|
name: string;
|
|
}
|
|
/** An empty argument slot between two `;` (or before the closing `)`), e.g.
|
|
* the middle `;;` in `XLOOKUP(x;a;b;;-1)`, skipping the optional
|
|
* `WennNichtGefunden` argument to reach `Vergleichsmodus` -- Excel's own
|
|
* "leave this optional argument out" idiom. Evaluates to `null` (empty
|
|
* cell), same as any other missing value in this engine. A function that
|
|
* needs to tell "explicitly omitted" apart from "evaluated to null" (e.g.
|
|
* XLOOKUP's optional trailing arguments) checks `arg.kind === 'omitted'`
|
|
* on the raw argument `Node` directly, before ever evaluating it. */
|
|
export interface OmittedNode {
|
|
kind: 'omitted';
|
|
}
|
|
/** A JSON value flowing through a macro's `env` (a `GET`/`POST` response, a
|
|
* `JSONGET` result, or one element while a `FOR` iterates a JSON array) --
|
|
* see evaluator.ts's `execStatementAsync`/`jsonGetPath`. Never produced by
|
|
* this parser (there is no JSON-literal syntax in the language) -- only
|
|
* the macro interpreter ever constructs one, wraps it in `literalNodeOf`-
|
|
* style env binding, and hands it back through `substitute()` like any
|
|
* other bound name. `evalScalar`'s `case 'json'` is where it collapses to
|
|
* an ordinary `CellValue` if used in plain scalar position without first
|
|
* drilling into it via `JSONGET`. */
|
|
export interface JsonLiteralNode {
|
|
kind: 'json';
|
|
value: unknown;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// "Block" function bodies (`kind: 'block'` custom functions) -- a small
|
|
// statement grammar with real control flow (FOR/DO/END loops, IF/THEN/
|
|
// ELSE/END branches, RETURN anywhere). NOT part of the `Node` union above:
|
|
// a block is parsed by parseFunctionBlock() (a distinct entry point from
|
|
// parseFormula()), and -- unlike Phase 1's single-expression bodies, which
|
|
// are fully inlined at parse time -- a block is only *partly* resolved at
|
|
// parse time (nested calls to other functions) and then *interpreted* at
|
|
// evaluation time by evaluator.ts, since a conditional/early RETURN depends
|
|
// on real cell values that aren't known until then. `BlockCallNode` (further
|
|
// below) is the one piece of this that *does* end up embedded in a normal
|
|
// cell AST and so *is* part of `Node`. See src/engine/README.md's
|
|
// "Schleifen" section for the full design.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** `name = value` -- a local-variable assignment. */
|
|
export interface AssignNode {
|
|
kind: 'assign';
|
|
name: string;
|
|
value: Node;
|
|
}
|
|
/** `RETURN value` -- may appear anywhere in a block (top level or nested in
|
|
* a FOR/IF), not just at the end; ends the function's execution immediately
|
|
* with this value once reached. */
|
|
export interface ReturnNode {
|
|
kind: 'return';
|
|
value: Node;
|
|
}
|
|
/** `FOR varName IN source DO body END` -- source must resolve, once
|
|
* parameters are substituted with the caller's real arguments, to a single
|
|
* cell or a range (checked by userFunctions.ts at save time, enforced by
|
|
* the interpreter at call time); `body` runs once per cell, in order. */
|
|
export interface ForNode {
|
|
kind: 'for';
|
|
varName: string;
|
|
source: Node;
|
|
body: BlockNode;
|
|
}
|
|
/** `IF cond THEN then [ELSE else] END`. May appear in any block, including
|
|
* nested inside a FOR's body or another IF's branch. */
|
|
export interface IfNode {
|
|
kind: 'ifstmt';
|
|
cond: Node;
|
|
then: BlockNode;
|
|
else: BlockNode | null;
|
|
}
|
|
/** `NAME(arg; arg; ...)` at statement position -- a call into the macro
|
|
* write-action vocabulary (`SETVALUE`, `INSERTROW`, `ADDTABLE`, ...; see
|
|
* engine/types.ts's `MacroWriteHost` and evaluator.ts's `execStatement`).
|
|
* Only ever produced when the parser's `allowWrites` flag is on, which is
|
|
* only true for `parseMacroBlock()` -- so this can *never* appear in a
|
|
* `kind: 'block'` custom function's body (always parsed with
|
|
* `allowWrites: false`), meaning a custom function embedded inside a
|
|
* macro's expressions can compute a value but can never itself write. */
|
|
export interface ActionNode {
|
|
kind: 'action';
|
|
name: string;
|
|
args: Node[];
|
|
}
|
|
export type Statement = AssignNode | ForNode | IfNode | ReturnNode | ActionNode;
|
|
/** A sequence of statements -- a whole function body, or the body of a
|
|
* FOR/IF. Every name a statement introduces (assignment target or loop
|
|
* variable) stays visible for the *rest* of the execution, including after
|
|
* its own FOR/IF ends (with its last value) -- flat scope, no nested block
|
|
* scoping. */
|
|
export interface BlockNode {
|
|
kind: 'block';
|
|
statements: Statement[];
|
|
}
|
|
/** The one block-related node that *is* part of `Node`: embedded into a
|
|
* normal cell AST wherever a `kind: 'block'` custom function is called
|
|
* (userFunctions.ts#expand(), replacing what a `kind: 'expression'` call
|
|
* would have inlined as a plain expression). `block` has already had every
|
|
* *other* function call it contains resolved (built-ins untouched, other
|
|
* custom functions inlined/turned into their own BlockCallNode) -- only
|
|
* this function's *own* parameters, referenced via ParamNode inside
|
|
* `block`, remain to be bound, which evaluator.ts's interpreter does at
|
|
* evaluation time using `args`/`paramNames`. */
|
|
export interface BlockCallNode {
|
|
kind: 'blockcall';
|
|
block: BlockNode;
|
|
args: Node[];
|
|
paramNames: string[];
|
|
/** the function's own name, purely for a clearer "ended without RETURN"
|
|
* error message. */
|
|
label: string;
|
|
}
|
|
|
|
export class ParseError extends Error {}
|
|
|
|
class Parser {
|
|
private i = 0;
|
|
/** Case-insensitively matched (compared upper-cased), like function names
|
|
* elsewhere in the engine (functions.ts's getFunction()). Mutable only for
|
|
* parseBlock() below: as each statement introduces a new local/loop-var
|
|
* name, it's added here so *later* statements/the RETURN can reference it
|
|
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
|
* this, so its behaviour is completely unaffected. */
|
|
private readonly paramNames: Set<string>;
|
|
/** Whether a bare, unqualified, non-cell, non-param identifier falls back
|
|
* to a `NamedRefNode` (an ordinary top-level formula) instead of a hard
|
|
* `ParseError` (parsing a function body) -- see reference()'s named-range
|
|
* branch and parseFormula()'s doc comment for why function bodies opt
|
|
* out of the fallback. */
|
|
private readonly namedRefFallback: boolean;
|
|
/** Whether `NAME(args)` at statement position parses as an `ActionNode`
|
|
* (a macro write-action call) instead of falling through to
|
|
* `parseAssignment()` (which then fails, since assignment requires "="
|
|
* right after the name). Only `parseMacroBlock()` turns this on -- see
|
|
* `ActionNode`'s doc comment for why that containment matters. */
|
|
private readonly allowWrites: boolean;
|
|
constructor(
|
|
private readonly toks: Token[],
|
|
paramNames: readonly string[] = [],
|
|
namedRefFallback = true,
|
|
allowWrites = false,
|
|
) {
|
|
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
|
this.namedRefFallback = namedRefFallback;
|
|
this.allowWrites = allowWrites;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** Entry point for a "block" function body: a plain statement sequence
|
|
* (`RETURN` is just one of the statement kinds now, usable anywhere --
|
|
* see parseFunctionBlock() below). */
|
|
parseBlock(): BlockNode {
|
|
const block = this.parseStatementBlock([]);
|
|
if (!this.at('EOF')) {
|
|
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
|
}
|
|
return block;
|
|
}
|
|
|
|
/** Parses statements until the current token is EOF or one of `stopWords`
|
|
* (a keyword that ends the enclosing construct -- "END"/"ELSE" -- left
|
|
* *unconsumed* for the caller to check/consume itself). */
|
|
private parseStatementBlock(stopWords: readonly string[]): BlockNode {
|
|
const statements: Statement[] = [];
|
|
while (!this.at('EOF') && !stopWords.some((w) => this.isKeyword(w))) {
|
|
statements.push(this.parseStatement());
|
|
}
|
|
return { kind: 'block', statements };
|
|
}
|
|
|
|
private parseStatement(): Statement {
|
|
if (this.isKeyword('FOR')) return this.parseForLoop();
|
|
if (this.isKeyword('IF')) return this.parseIfStmt();
|
|
if (this.isKeyword('RETURN')) return this.parseReturnStmt();
|
|
// "NAME(" at statement start, only inside a macro body -- unambiguous
|
|
// vs. assignment, which requires "=" (a different token type) right
|
|
// after the name.
|
|
if (this.allowWrites && this.at('IDENT') && this.toks[this.i + 1]?.type === 'LPAREN') {
|
|
return this.parseAction();
|
|
}
|
|
return this.parseAssignment();
|
|
}
|
|
|
|
private parseAction(): ActionNode {
|
|
const nameTok = this.next(); // IDENT, guaranteed by the caller's lookahead
|
|
this.next(); // consume "("
|
|
const args = this.parseArgList();
|
|
this.expect('RPAREN');
|
|
return { kind: 'action', name: nameTok.value, args };
|
|
}
|
|
|
|
/** `[ arg { ";" arg } ]` -- shared by a function-call expression
|
|
* (`primary()`) and a statement-position action call (`parseAction()`).
|
|
* An empty slot (leading/middle/trailing `;`, right up against `;` or
|
|
* the caller's closing `)`) parses as an `OmittedNode`, same as a
|
|
* function call's own argument list -- the caller consumes the closing
|
|
* `)` itself. */
|
|
private parseArgList(): Node[] {
|
|
const args: Node[] = [];
|
|
const argOrOmitted = (): Node =>
|
|
this.at('SEMICOLON') || this.at('RPAREN') ? { kind: 'omitted' } : this.expression();
|
|
if (!this.at('RPAREN')) {
|
|
args.push(argOrOmitted());
|
|
while (this.at('SEMICOLON')) {
|
|
this.next();
|
|
args.push(argOrOmitted());
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
private parseAssignment(): AssignNode {
|
|
if (!this.at('IDENT')) {
|
|
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
|
|
}
|
|
const nameTok = this.next();
|
|
if (!this.at('EQUALS')) {
|
|
throw new ParseError(`Expected '=' after '${nameTok.value}' at ${this.peek().pos}`);
|
|
}
|
|
this.next();
|
|
const value = this.expression();
|
|
this.paramNames.add(nameTok.value.toUpperCase()); // visible from here on (flat scope)
|
|
return { kind: 'assign', name: nameTok.value, value };
|
|
}
|
|
|
|
private parseReturnStmt(): ReturnNode {
|
|
this.next(); // consume RETURN
|
|
const value = this.expression();
|
|
return { kind: 'return', value };
|
|
}
|
|
|
|
private parseForLoop(): ForNode {
|
|
this.next(); // consume FOR
|
|
if (!this.at('IDENT')) {
|
|
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
|
|
}
|
|
const varTok = this.next();
|
|
this.expectKeyword('IN', `FOR ${varTok.value}`);
|
|
const source = this.expression();
|
|
this.expectKeyword('DO', 'the FOR source');
|
|
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the body (and after)
|
|
const body = this.parseStatementBlock(['END']);
|
|
this.expectKeyword('END', 'the FOR body');
|
|
return { kind: 'for', varName: varTok.value, source, body };
|
|
}
|
|
|
|
private parseIfStmt(): IfNode {
|
|
this.next(); // consume IF
|
|
const cond = this.expression();
|
|
this.expectKeyword('THEN', 'the IF condition');
|
|
const then = this.parseStatementBlock(['ELSE', 'END']);
|
|
let elseBlock: BlockNode | null = null;
|
|
if (this.isKeyword('ELSE')) {
|
|
this.next();
|
|
elseBlock = this.parseStatementBlock(['END']);
|
|
}
|
|
this.expectKeyword('END', 'the IF body');
|
|
return { kind: 'ifstmt', cond, then, else: elseBlock };
|
|
}
|
|
|
|
/** True iff the current token is an IDENT spelling exactly `word`
|
|
* (case-insensitive) -- how FOR/IN/DO/END/IF/THEN/ELSE/RETURN are
|
|
* recognised, the same way function names are matched elsewhere (no
|
|
* reserved-word lexer tokens). */
|
|
private isKeyword(word: string): boolean {
|
|
return this.at('IDENT') && this.peek().value.toUpperCase() === word;
|
|
}
|
|
|
|
/** Consumes `word` as a keyword, or throws a ParseError naming what came
|
|
* before it (for a clearer message than a generic "expected IDENT"). */
|
|
private expectKeyword(word: string, afterWhat: string): void {
|
|
if (!this.isKeyword(word)) {
|
|
throw new ParseError(`Expected ${word} after ${afterWhat} at ${this.peek().pos}`);
|
|
}
|
|
this.next();
|
|
}
|
|
|
|
/** Public entry point for every expression context (function args,
|
|
* parens, assignment RHS, ...) -- wraps `additive` with an optional
|
|
* single comparison, the loosest-binding operator. */
|
|
private expression(): Node {
|
|
const left = this.concat();
|
|
const opType = this.comparisonOpType();
|
|
if (!opType) return left;
|
|
this.next();
|
|
return { kind: 'binary', op: opType, left, right: this.concat() };
|
|
}
|
|
|
|
private comparisonOpType(): BinaryNode['op'] | null {
|
|
switch (this.peek().type) {
|
|
case 'EQUALS':
|
|
return '=';
|
|
case 'NEQ':
|
|
return '<>';
|
|
case 'GT':
|
|
return '>';
|
|
case 'LT':
|
|
return '<';
|
|
case 'GTE':
|
|
return '>=';
|
|
case 'LTE':
|
|
return '<=';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** `&` -- text concatenation, left-associative. Binds looser than `+ -`
|
|
* (so `="a"&1+2` is `"a"&(1+2)` = `"a3"`, not `("a"&1)+2`) but tighter
|
|
* than a comparison (so `=A1&B1="ab"` is `(A1&B1)="ab"`, not
|
|
* `A1&(B1="ab")`) -- same slot Excel gives `&`. */
|
|
private concat(): Node {
|
|
let left = this.additive();
|
|
while (this.at('AMP')) {
|
|
this.next();
|
|
left = { kind: 'binary', op: '&', left, right: this.additive() };
|
|
}
|
|
return left;
|
|
}
|
|
|
|
/** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
|
|
* `expression` itself gained the comparison wrapper above it. */
|
|
private additive(): 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') {
|
|
// `1:1` / `2:5` -- a whole-row reference, not a number
|
|
if (wholeLineAt(this.toks, this.i)) return this.reference(null, null);
|
|
this.next();
|
|
return { kind: 'number', value: Number(t.value) };
|
|
}
|
|
if (t.type === 'STRING') {
|
|
this.next();
|
|
return { kind: 'string', value: t.value };
|
|
}
|
|
if (t.type === 'ERRORLIT') {
|
|
this.next();
|
|
return { kind: 'errorliteral', code: t.value as ErrorCode };
|
|
}
|
|
if (t.type === 'LPAREN') {
|
|
this.next();
|
|
const inner = this.expression();
|
|
this.expect('RPAREN');
|
|
return inner;
|
|
}
|
|
if (t.type === 'QUOTED') {
|
|
// must be a qualified reference -- afterFirstQualifierToken() figures
|
|
// out whether it's a table qualifier ('Kosten Q3'!B2) or a sheet
|
|
// qualifier starting a 3-part ('Analyse'!Umsatz::B2) reference.
|
|
this.next();
|
|
return this.afterFirstQualifierToken(t.value);
|
|
}
|
|
if (t.type === 'IDENT') {
|
|
this.next();
|
|
// function call?
|
|
if (this.at('LPAREN')) {
|
|
this.next();
|
|
const args = this.parseArgList();
|
|
this.expect('RPAREN');
|
|
return { kind: 'func', name: t.value, args };
|
|
}
|
|
// qualified reference? IDENT ("!" | "::") ...
|
|
if (this.at('BANG') || this.at('DBLCOLON')) {
|
|
return this.afterFirstQualifierToken(t.value);
|
|
}
|
|
// otherwise it must be a local cell reference
|
|
return this.reference(null, null, t);
|
|
}
|
|
|
|
throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`);
|
|
}
|
|
|
|
/**
|
|
* Called right after consuming the first IDENT/QUOTED of what is *some*
|
|
* kind of qualified reference (current token is known to be "!" or "::").
|
|
* Disambiguates the two qualifier grammars:
|
|
* - "X::..." or a bare "X!..." (not followed by another name + "::") is
|
|
* the original, single-level TABLE qualifier -- unchanged from before
|
|
* this existed, so every formula written before the sheet qualifier
|
|
* was added still parses exactly the same.
|
|
* - "X!Y::..." -- when "!" is immediately followed by another
|
|
* IDENT/QUOTED and *that* is followed by "::" -- is the 3-part form:
|
|
* X is the SHEET, Y is the TABLE.
|
|
*/
|
|
private afterFirstQualifierToken(first: string): Node {
|
|
if (this.at('BANG') && this.looksLikeSheetThenTable()) {
|
|
this.next(); // consume "!"
|
|
const tableTok = this.next(); // IDENT or QUOTED, guaranteed by the lookahead above
|
|
this.next(); // consume "::"
|
|
return this.reference(tableTok.value, first);
|
|
}
|
|
if (this.at('BANG') || this.at('DBLCOLON')) {
|
|
this.next();
|
|
return this.reference(first, null);
|
|
}
|
|
throw new ParseError(`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`);
|
|
}
|
|
|
|
/** True iff, with the current token being "!", the shape ahead is
|
|
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
|
|
* followed by a table qualifier, not a bare (old-style) table qualifier
|
|
* of its own. Pure lookahead, consumes nothing. */
|
|
private looksLikeSheetThenTable(): boolean {
|
|
const n1 = this.toks[this.i + 1];
|
|
const n2 = this.toks[this.i + 2];
|
|
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
|
|
}
|
|
|
|
/**
|
|
* 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, sheetQualifier: string | null, firstTok?: Token): Node {
|
|
// whole column `B:B` / whole row `1:1` -- checked first: `B` alone would
|
|
// otherwise be a named-range candidate and `1` a number
|
|
const line = wholeLineAt(this.toks, firstTok ? this.i - 1 : this.i);
|
|
if (line) {
|
|
if (!firstTok) this.next();
|
|
this.next(); // ":"
|
|
this.next(); // second part
|
|
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
|
|
return line.axis === 'col'
|
|
? {
|
|
kind: 'range',
|
|
qualifier,
|
|
...sheetField,
|
|
start: { col: line.start, row: 0 },
|
|
end: { col: line.end, row: WHOLE_LINE_END },
|
|
whole: 'col',
|
|
}
|
|
: {
|
|
kind: 'range',
|
|
qualifier,
|
|
...sheetField,
|
|
start: { col: 0, row: line.start },
|
|
end: { col: WHOLE_LINE_END, row: line.end },
|
|
whole: 'row',
|
|
};
|
|
}
|
|
const a = firstTok ?? this.expectCellIdent();
|
|
if (!firstTok && a.type !== 'IDENT') {
|
|
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
|
}
|
|
// an unqualified name matching a declared parameter -- only reachable
|
|
// when parseFormula() was given a paramNames list; a param is always a
|
|
// scalar, so it's never part of a qualifier or a ":"-range, only ever
|
|
// this single bare token.
|
|
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
|
return { kind: 'param', name: a.value };
|
|
}
|
|
// an unqualified, non-cell-shaped bare name -- a named-range candidate
|
|
// (see NamedRefNode's doc comment), but only for an ordinary top-level
|
|
// formula (namedRefFallback); a function body keeps the strict old
|
|
// behaviour so e.g. a local used before its own declaring statement
|
|
// still fails immediately at save time instead of silently becoming a
|
|
// named-range lookup (or worse, one that happens to resolve because a
|
|
// same-named named range exists elsewhere in the document) -- a caller
|
|
// can still *pass* a named range as an argument, it just can't be typed
|
|
// bare inside the body itself. Never a range-start either way: "Name:B2"
|
|
// isn't supported, so this doesn't fall through to the ":"-range check.
|
|
if (qualifier === null && !looksLikeCell(a.value) && this.namedRefFallback) {
|
|
return { kind: 'namedref', name: a.value };
|
|
}
|
|
if (!looksLikeCell(a.value)) {
|
|
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
|
}
|
|
const start = parseA1(a.value);
|
|
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
|
|
|
|
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, ...sheetField, start, end };
|
|
}
|
|
return { kind: 'cell', qualifier, ...sheetField, col: start.col, row: start.row };
|
|
}
|
|
|
|
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.
|
|
*
|
|
* `paramNames`, when given, is a user-defined function's parameter list --
|
|
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
|
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
|
* cell formula; behaviour is then identical to before this existed, *except*
|
|
* that a bare, unqualified, non-cell identifier now parses as a
|
|
* {@link NamedRefNode} (a named-range candidate) instead of a ParseError --
|
|
* see NamedRefNode's doc comment. That fallback only applies when
|
|
* `paramNames` is omitted entirely (`undefined`) -- passing it, even as an
|
|
* empty array, means "this is a function body", where the fallback is
|
|
* deliberately off (see the Parser class's `namedRefFallback` field).
|
|
*/
|
|
export function parseFormula(src: string, paramNames?: readonly 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, paramNames, paramNames === undefined).parse();
|
|
}
|
|
|
|
/**
|
|
* Repair the one mistake that is almost always just forgotten typing: a
|
|
* formula committed with Enter while parentheses (or a "..." text literal)
|
|
* are still open, e.g. `=SUMME(A1:A5` or `=WENN(A1>0;"ja`. Same as Excel and
|
|
* Numbers, the missing closers are appended at the end.
|
|
*
|
|
* Deliberately conservative: anything that already parses, isn't a formula,
|
|
* or still doesn't parse after closing (`=SUMME(A1;`, a stray `)` too many)
|
|
* comes back unchanged, so the cell shows its usual parse error instead of a
|
|
* "repair" that guessed something the user never meant.
|
|
*/
|
|
export function completeFormula(input: string): string {
|
|
if (!input.startsWith('=')) return input;
|
|
const parses = (src: string) => {
|
|
try {
|
|
parseFormula(src);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
if (parses(input)) return input;
|
|
|
|
let fixed = input;
|
|
let toks: Token[];
|
|
try {
|
|
toks = tokenize(fixed.slice(1));
|
|
} catch (e) {
|
|
// an unterminated "..." literal swallows everything up to the end, so
|
|
// closing it first is the only way to count the parentheses before it
|
|
if (!(e instanceof LexError) || !e.message.startsWith('Unterminated string')) return input;
|
|
fixed += '"';
|
|
try {
|
|
toks = tokenize(fixed.slice(1));
|
|
} catch {
|
|
return input;
|
|
}
|
|
}
|
|
let depth = 0;
|
|
for (const t of toks) {
|
|
if (t.type === 'LPAREN') depth++;
|
|
else if (t.type === 'RPAREN' && --depth < 0) return input;
|
|
}
|
|
fixed += ')'.repeat(depth);
|
|
return fixed !== input && parses(fixed) ? fixed : input;
|
|
}
|
|
|
|
/**
|
|
* Parse a "block" function body -- the statement grammar (assignments,
|
|
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
|
|
* `kind: 'block'` custom function (see src/engine/userFunctions.ts,
|
|
* evaluator.ts's interpreter, and CustomFunctionsModal.vue). The leading
|
|
* "=" is optional, same as parseFormula(). Throws {@link ParseError} /
|
|
* {@link LexError} on malformed input, same conventions as parseFormula().
|
|
*/
|
|
export function parseFunctionBlock(src: string, paramNames: readonly string[]): BlockNode {
|
|
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, paramNames, false).parseBlock();
|
|
}
|
|
|
|
/**
|
|
* Parse a macro body -- the same statement grammar as `parseFunctionBlock`
|
|
* (assignments, `FOR`/`IF`/`RETURN`), plus `NAME(args)` write-action calls
|
|
* (`ActionNode`; see its doc comment), which only this entry point enables.
|
|
* A macro takes no parameters (triggered by a click, not called with
|
|
* arguments) and gets no named-range fallback (same reasoning as a function
|
|
* body: a bare identifier used before its own declaring statement should
|
|
* fail immediately, not silently resolve to a same-named document-wide
|
|
* named range) -- so `paramNames`/`namedRefFallback` are fixed at `[]`/
|
|
* `false`, only `allowWrites` differs from `parseFunctionBlock`.
|
|
*/
|
|
export function parseMacroBlock(src: string): BlockNode {
|
|
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, [], false, true).parseBlock();
|
|
}
|
|
|
|
/** Walk an AST and collect every cell/range/named-range reference node it
|
|
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
|
* every expression reachable inside its `block` -- conservatively: a
|
|
* reference inside a branch/loop that might not execute at runtime still
|
|
* counts as a dependency, same as an ordinary `IF`-style formula in any
|
|
* spreadsheet. A `NamedRefNode` is collected as-is, unresolved -- this
|
|
* function is host-agnostic (doesn't know the document's named ranges),
|
|
* same as it never resolves a table qualifier either; the caller
|
|
* (`Workbook.precedentsOf`) is the one that turns it into concrete cell
|
|
* keys via `resolveNamedRange()`. */
|
|
export function collectRefs(
|
|
node: Node,
|
|
out: Array<CellNode | RangeNode | NamedRefNode> = [],
|
|
): Array<CellNode | RangeNode | NamedRefNode> {
|
|
switch (node.kind) {
|
|
case 'cell':
|
|
case 'range':
|
|
case 'namedref':
|
|
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;
|
|
case 'blockcall':
|
|
node.args.forEach((a) => collectRefs(a, out));
|
|
collectRefsInBlock(node.block, out);
|
|
break;
|
|
case 'json':
|
|
break; // a JSON value carries no cell/range/named-range refs of its own
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Exported for the macro save-time validator (store/document.ts), which
|
|
* walks every `CellNode`/`RangeNode` this finds to reject an unqualified
|
|
* reference -- a macro has no "home table" a bare `A1` could mean, unlike
|
|
* a formula cell (see store/document.ts's `validateMacro`). */
|
|
export function collectRefsInBlock(
|
|
block: BlockNode,
|
|
out: Array<CellNode | RangeNode | NamedRefNode> = [],
|
|
): Array<CellNode | RangeNode | NamedRefNode> {
|
|
for (const stmt of block.statements) {
|
|
switch (stmt.kind) {
|
|
case 'assign':
|
|
collectRefs(stmt.value, out);
|
|
break;
|
|
case 'return':
|
|
collectRefs(stmt.value, out);
|
|
break;
|
|
case 'for':
|
|
collectRefs(stmt.source, out);
|
|
collectRefsInBlock(stmt.body, out);
|
|
break;
|
|
case 'ifstmt':
|
|
collectRefs(stmt.cond, out);
|
|
collectRefsInBlock(stmt.then, out);
|
|
if (stmt.else) collectRefsInBlock(stmt.else, out);
|
|
break;
|
|
case 'action':
|
|
stmt.args.forEach((a) => collectRefs(a, out));
|
|
break;
|
|
}
|
|
}
|
|
return out;
|
|
}
|