/** * 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; /** 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 = [], ): Array { 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 = [], ): Array { 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; }