The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
881 lines
36 KiB
TypeScript
881 lines
36 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();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|