Add Rails backend: accounts via magic link, server documents, sharing

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>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+880
View File
@@ -0,0 +1,880 @@
/**
* 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;
}