Makros: Engine- und Store-Fundament (Zellen/Zeilen/Spalten/Tabellen/Blätter per Skript)

Erster von zwei Commits (UI folgt separat): neue, explizit ausgelöste
Makro-Sprache — dieselbe block-mode-Grammatik wie bei "Mit Schleife"-
Funktionen (FOR/IF/RETURN), plus ein neues Schreib-Vokabular (SETVALUE,
INSERTROW/DELETEROW/INSERTCOL/DELETECOL, ADDTABLE/RENAMETABLE/DELETETABLE,
ADDSHEET/RENAMESHEET/DELETESHEET), adressiert mit der bestehenden
Blatt!Tabelle::Zelle-Syntax.

Bewusst außerhalb der normalen Neuberechnung: ein Makro läuft nie aus einer
Zellformel heraus, sondern nur explizit getriggert (store.runMacro), und
mutiert ausschließlich über die bereits vorhandenen Store-Primitiven
(setCellInput, insertRow, addTable, addSheet, …) — Workbook/EngineHost/
DependencyGraph bleiben unverändert rein lesend. Ein ganzer synchroner
Makro-Lauf wird automatisch zu einem einzigen Undo-Schritt.

50 neue Tests (Engine + Store), inkl. eines gezielten Regressionstests für
die zentrale Korrektheits-Fallgrube: eine strukturelle Aktion mitten im
Lauf ersetzt die Workbook-Instanz komplett, jeder Host-Zugriff muss daher
frisch aufgelöst werden statt eine Referenz zu cachen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 09:35:02 +02:00
co-authored by Claude Sonnet 5
parent c7b316de1b
commit e670f13077
13 changed files with 1188 additions and 36 deletions
+227
View File
@@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest';
import { parseMacroBlock } from '../parser';
import { runMacroActions } from '../evaluator';
import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions';
import { FormulaError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
/** Same shape as evaluator.spec.ts's makeHost, but resolves any of a small
* fixed set of table names ("Umsatz"/"Kosten" -- matching this file's test
* formulas, chosen to read naturally rather than the generic "S1"/"S2")
* case-insensitively to themselves, since a macro test cares about which
* name got resolved, not an opaque id. */
function makeHost(
data: Record<string, Record<string, CellValue>>,
home = 'Umsatz',
namedRanges: Record<string, NamedRangeShape> = {},
): EngineHost {
const known = ['Umsatz', 'Kosten'];
const idFor = (name: string): string | null => known.find((t) => t.toLowerCase() === name.trim().toLowerCase()) ?? null;
return {
homeTableId: home,
resolveTableName: idFor,
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
tableSize: () => ({ cols: 10, rows: 2000 }),
getComputed(tableId, col, row) {
const cell = String.fromCharCode(65 + col) + (row + 1);
const v = data[tableId]?.[cell];
return v === undefined ? null : v;
},
};
}
type RecordedCall = { op: string; args: unknown[] };
/** Records every call instead of mutating anything -- these tests are about
* the interpreter dispatching the right action with the right resolved
* arguments, not about a real document (that's store/document.spec.ts's
* job, against the real store primitives). */
function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
const calls: RecordedCall[] = [];
const record =
(op: string) =>
(...args: unknown[]): void => {
calls.push({ op, args });
};
const host: MacroWriteHost = {
setValue: record('setValue'),
insertRow: record('insertRow'),
deleteRow: record('deleteRow'),
insertCol: record('insertCol'),
deleteCol: record('deleteCol'),
addTable: record('addTable'),
renameTable: record('renameTable'),
deleteTable: record('deleteTable'),
addSheet: record('addSheet'),
renameSheet: record('renameSheet'),
deleteSheet: record('deleteSheet'),
};
return { host, calls };
}
const runMacro = (src: string, host: EngineHost, writeHost: MacroWriteHost) =>
runMacroActions(parseMacroBlock(src), host, writeHost);
describe('runMacroActions -- SETVALUE target resolution', () => {
it('a literal cell target writes exactly one cell', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]);
});
it('a range target broadcasts the same value to every cell in one call', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
expect(calls).toHaveLength(1);
const [cells, text] = calls[0].args as [CellRef[], string];
expect(cells).toEqual([
{ tableId: 'Umsatz', col: 0, row: 0 },
{ tableId: 'Umsatz', col: 0, row: 1 },
{ tableId: 'Umsatz', col: 0, row: 2 },
]);
expect(text).toBe('x');
});
it('a target resolved through a FOR loop variable (substitute() through env)', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
expect(calls).toEqual([
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] },
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] },
]);
});
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
expect(calls[0].args[1]).toBe('=SUM(A2:A3)');
});
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).toThrow(/Tabelle nennen/);
});
it('rejects a computed (non-reference) target', () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).toThrow(/Zell- oder Bereichsbezug/);
});
it('rejects an unknown table name', () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).toThrow(/unbekannte Tabelle/);
});
});
describe('runMacroActions -- RETURN / fall-through', () => {
it('RETURN short-circuits and its value is returned', () => {
const { host: writeHost } = makeWriteHost();
expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).toBe('fertig');
});
it('a RETURN inside an IF stops the remaining statements from running', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
expect(calls).toHaveLength(0);
});
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', () => {
const { host: writeHost } = makeWriteHost();
expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).toBeNull();
});
});
describe('runMacroActions -- the full action vocabulary dispatches correctly', () => {
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro(
[
'INSERTROW("Umsatz"; 3)',
'DELETEROW("Umsatz"; 4)',
'INSERTCOL("Umsatz"; 1)',
'DELETECOL("Umsatz"; 2)',
'ADDTABLE("Blatt1"; "Neu")',
'RENAMETABLE("Umsatz"; "Verkauf")',
'DELETETABLE("Alt")',
'ADDSHEET("Blatt2")',
'RENAMESHEET("Blatt1"; "Analyse")',
'DELETESHEET("Blatt3")',
].join(' '),
makeHost({}),
writeHost,
);
expect(calls).toEqual([
{ op: 'insertRow', args: ['Umsatz', 3] },
{ op: 'deleteRow', args: ['Umsatz', 4] },
{ op: 'insertCol', args: ['Umsatz', 1] },
{ op: 'deleteCol', args: ['Umsatz', 2] },
{ op: 'addTable', args: ['Blatt1', 'Neu'] },
{ op: 'renameTable', args: ['Umsatz', 'Verkauf'] },
{ op: 'deleteTable', args: ['Alt'] },
{ op: 'addSheet', args: ['Blatt2'] },
{ op: 'renameSheet', args: ['Blatt1', 'Analyse'] },
{ op: 'deleteSheet', args: ['Blatt3'] },
]);
});
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
});
it('an unknown action name throws', () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('FOOBAR(1)', makeHost({}), writeHost)).toThrow(/Unbekannte Aktion/);
});
it('a wrong argument count throws', () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).toThrow(/erwartet 1 Argument/);
});
});
describe('runMacroActions -- macro-specific step budget', () => {
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', () => {
const { host: writeHost } = makeWriteHost();
// 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS
// (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS
// (10,000) an ordinary block-kind function loop gets.
expect(() =>
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
).toThrow(/zu viele Schritte|zu viele Zellen/);
});
});
describe('runMacroActions -- nested custom-function calls inside a macro expression', () => {
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', () => {
const { host: writeHost, calls } = makeWriteHost();
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' },
];
const index = buildFunctionIndex(defs);
const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index);
runMacroActions(block, makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
});
it('an ActionNode can never appear inside a nested custom-function body -- parseFunctionBlock (always allowWrites:false) rejects write syntax even if a macro author tried to sneak one in', () => {
expect(() => {
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'BAD', params: [], body: 'SETVALUE(A1;1) RETURN 1', kind: 'block' },
];
const index = buildFunctionIndex(defs);
expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; BAD())'), index);
}).toThrow();
});
});
describe('runMacroActions -- error propagation', () => {
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).toThrow(
new FormulaError('#DIV/0!').code,
);
});
});
+66
View File
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
import {
parseFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
collectRefsInBlock,
ParseError,
type Node,
type Statement,
@@ -54,6 +56,8 @@ function sexprStmt(s: Statement): string {
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
case 'ifstmt':
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
case 'action':
return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`;
}
}
function sexprBlock(b: BlockNode): string {
@@ -384,3 +388,65 @@ describe('parseFunctionBlock -- statement grammar for "block" function bodies',
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
});
});
describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => {
const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s));
it('a bare NAME(args) at statement position parses as an action call', () => {
expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe(
'{ACTION(SETVALUE;Umsatz!cell(0,0);42)}',
);
});
it('an action with no arguments', () => {
expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}');
});
it('";"-separated arguments, including an omitted middle slot', () => {
expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}');
});
it('an action call composes with local variables and & concatenation', () => {
expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe(
'{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}',
);
});
it('an action call can appear inside FOR/IF bodies', () => {
expect(
parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'),
).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}');
});
it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => {
expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}');
});
it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => {
// Same source: a function body has no `allowWrites`, so "SETVALUE(...)"
// at statement start falls through to parseAssignment(), which then
// fails immediately for lack of "=".
expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError);
});
it('rejects an action call missing its closing ")"', () => {
expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError);
});
});
describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => {
it('collects a SETVALUE action\'s reference arguments too', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)');
const refs = collectRefsInBlock(block);
expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']);
expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']);
});
it('collects refs from every action argument, not just the first', () => {
const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)');
const refs = collectRefsInBlock(block);
// INSERTROW's args are plain strings/numbers -- no refs from that
// statement; SETVALUE contributes both its target and its value's ref.
expect(refs).toHaveLength(2);
});
});
+27 -1
View File
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest';
import { parseFormula } from '../parser';
import { parseFormula, parseMacroBlock } from '../parser';
import {
buildFunctionIndex,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
expandMacroBlock,
expandUserFunctions,
type FunctionDef,
} from '../userFunctions';
@@ -136,6 +138,30 @@ describe('collectCalledFunctionNames', () => {
});
});
describe('expandMacroBlock / collectCalledFunctionNamesInBlock: the new "action" statement arm', () => {
it('expandMacroBlock resolves a custom-function call nested inside an action argument', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))');
const expanded = expandMacroBlock(block, index);
const stmt = expanded.statements[0];
expect(stmt.kind).toBe('action');
if (stmt.kind !== 'action') throw new Error('unreachable');
// MARGE(10;3) was inlined (kind:'expression') into its (10 - 3) body --
// no more 'func' node calling MARGE survives in the expanded tree.
expect(collectCalledFunctionNames(stmt.args[1])).toEqual(new Set());
});
it('expandMacroBlock is a no-op fast path when no custom functions are defined', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; 1)');
expect(expandMacroBlock(block, buildFunctionIndex([]))).toBe(block); // same reference, not just equal
});
it('collectCalledFunctionNamesInBlock finds a function called inside an action\'s arguments', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3)) INSERTROW("Umsatz"; COUNT(Umsatz::A1:A3))');
expect(collectCalledFunctionNamesInBlock(block)).toEqual(new Set(['MARGE', 'COUNT']));
});
});
describe('Workbook integration: user-defined functions end-to-end', () => {
function fixture(functionDefs: FunctionDef[] = []): Workbook {
const umsatz = sheet('s-umsatz', 'Umsatz', {
+181 -11
View File
@@ -39,11 +39,11 @@
* ordinary function call this can't be resolved any earlier.
*/
import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import { getFunction, isTruthy } from './functions';
import { expandRange } from './refs';
import { substitute } from './userFunctions';
import { FormulaError, isError, type CellValue, type EngineHost } from './types';
import { FormulaError, isError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue {
return evalScalar(ast, host);
@@ -345,9 +345,19 @@ class ReturnSignal {
* either way. Generous for anything this app's tables realistically hold. */
const MAX_LOOP_STEPS = 10_000;
/** Same idea as {@link MAX_LOOP_STEPS}, but much smaller and shared across
* BOTH loop iterations and write-action calls within one macro run (see
* `runMacroActions`) -- a macro loop iteration can trigger a full
* `syncWorkbook()` rebuild (a structural action like `INSERTROW`), so the
* budget sized for pure value computation would let one bad macro freeze
* the tab for up to 10,000 rebuilds. Still generous for a per-click action. */
const MAX_MACRO_STEPS = 1_000;
type Budget = { steps: number; limit: number };
function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
const env = new Map<string, Node>(node.paramNames.map((p, i) => [p.toUpperCase(), node.args[i]]));
const budget = { steps: 0 };
const budget: Budget = { steps: 0, limit: MAX_LOOP_STEPS };
try {
execBlock(node.block, env, host, budget);
} catch (e) {
@@ -359,9 +369,41 @@ function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
throw new Error(`'${node.label}' ended without RETURN`);
}
function execBlock(block: BlockNode, env: Map<string, Node>, host: EngineHost, budget: { steps: number }): void {
/**
* Interprets a macro body (`parseMacroBlock()`'s output) OUTSIDE the normal
* recalculation pass -- see engine/README.md's "Makros" section. Unlike
* `evaluateBlockCall`, no RETURN is required: falling off the end simply
* means "done", returning `null` (a macro has no caller expecting a value;
* RETURN is still usable for an early exit / a status message, see the
* `'return'` case in `execStatement`). `writeHost` is what makes the new
* `'action'` statement kind (SETVALUE/INSERTROW/...) possible -- see its
* doc comment in types.ts. `host` MUST be a proxy that re-resolves against
* the live engine on every call, never a cached reference: a structural
* action mid-run (INSERTROW, ADDTABLE, ...) replaces the underlying engine
* instance wholesale (see store/document.ts's `syncWorkbook`), and a cached
* reference would silently go stale for every read after that point.
*/
export function runMacroActions(block: BlockNode, host: EngineHost, writeHost: MacroWriteHost): CellValue | null {
const env = new Map<string, Node>();
const budget: Budget = { steps: 0, limit: MAX_MACRO_STEPS };
try {
execBlock(block, env, host, budget, writeHost);
} catch (e) {
if (e instanceof ReturnSignal) return e.value;
throw e;
}
return null;
}
function execBlock(
block: BlockNode,
env: Map<string, Node>,
host: EngineHost,
budget: Budget,
writeHost?: MacroWriteHost,
): void {
for (const stmt of block.statements) {
execStatement(stmt, env, host, budget);
execStatement(stmt, env, host, budget, writeHost);
}
}
@@ -388,7 +430,8 @@ function execStatement(
stmt: Statement,
env: Map<string, Node>,
host: EngineHost,
budget: { steps: number },
budget: Budget,
writeHost?: MacroWriteHost,
): void {
switch (stmt.kind) {
case 'assign': {
@@ -404,8 +447,8 @@ function execStatement(
case 'ifstmt': {
const c = evalInEnv(stmt.cond, env, host);
if (isError(c)) throw new ReturnSignal(c);
if (isTruthy(c)) execBlock(stmt.then, env, host, budget);
else if (stmt.else) execBlock(stmt.else, env, host, budget);
if (isTruthy(c)) execBlock(stmt.then, env, host, budget, writeHost);
else if (stmt.else) execBlock(stmt.else, env, host, budget, writeHost);
return;
}
case 'for': {
@@ -413,17 +456,144 @@ function execStatement(
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
for (const cell of cells) {
budget.steps++;
if (budget.steps > MAX_LOOP_STEPS) {
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${MAX_LOOP_STEPS} insgesamt)`);
if (budget.steps > budget.limit) {
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${budget.limit} insgesamt)`);
}
env.set(stmt.varName.toUpperCase(), cell);
execBlock(stmt.body, env, host, budget);
execBlock(stmt.body, env, host, budget, writeHost);
}
return;
}
case 'action': {
if (!writeHost) {
// Can't happen via parseMacroBlock (only entry point that produces
// ActionNode) -- a defensive guard, not a reachable user-facing error.
throw new Error(`Aktion '${stmt.name}' außerhalb eines Makros nicht erlaubt`);
}
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`Makro führt zu viele Schritte aus (> ${budget.limit} insgesamt)`);
}
execAction(stmt, env, host, writeHost);
return;
}
}
}
/** Dispatches one macro write-action call by name -- see types.ts's
* `MacroWriteHost` for the full vocabulary and engine/README.md's
* "Makros" section for the syntax. Only `SETVALUE`'s first argument is
* special (resolved as a write TARGET, never evaluated as a value); every
* other argument of every action goes through the ordinary `evalInEnv`
* pipeline, so local variables/loop variables/`&`-concatenation all work
* for free without any per-action plumbing. */
function execAction(stmt: ActionNode, env: Map<string, Node>, host: EngineHost, writeHost: MacroWriteHost): void {
const upper = stmt.name.toUpperCase();
const arity = (n: number): void => {
if (stmt.args.length !== n) {
throw new Error(`${upper}: erwartet ${n} Argument${n === 1 ? '' : 'e'}, ${stmt.args.length} übergeben`);
}
};
const str = (i: number): string => requireString(evalInEnv(stmt.args[i], env, host), upper);
const num = (i: number): number => requireNumber(evalInEnv(stmt.args[i], env, host), upper);
switch (upper) {
case 'SETVALUE': {
arity(2);
const target = resolveWriteTarget(substitute(stmt.args[0], env), host);
const v = evalInEnv(stmt.args[1], env, host);
if (isError(v)) throw new Error(`SETVALUE: ${v.code}`);
const text = toText(v);
if (isError(text)) throw new Error(`SETVALUE: ${text.code}`);
writeHost.setValue(target, text);
return;
}
case 'INSERTROW':
arity(2);
return writeHost.insertRow(str(0), num(1));
case 'DELETEROW':
arity(2);
return writeHost.deleteRow(str(0), num(1));
case 'INSERTCOL':
arity(2);
return writeHost.insertCol(str(0), num(1));
case 'DELETECOL':
arity(2);
return writeHost.deleteCol(str(0), num(1));
case 'ADDTABLE':
arity(2);
return writeHost.addTable(str(0), str(1));
case 'RENAMETABLE':
arity(2);
return writeHost.renameTable(str(0), str(1));
case 'DELETETABLE':
arity(1);
return writeHost.deleteTable(str(0));
case 'ADDSHEET':
arity(1);
return writeHost.addSheet(str(0));
case 'RENAMESHEET':
arity(2);
return writeHost.renameSheet(str(0), str(1));
case 'DELETESHEET':
arity(1);
return writeHost.deleteSheet(str(0));
default:
throw new Error(`Unbekannte Aktion '${stmt.name}'`);
}
}
function requireString(v: CellValue, ctx: string): string {
if (isError(v)) throw new Error(`${ctx}: ${v.code}`);
if (typeof v !== 'string') throw new Error(`${ctx}: erwartet einen Text als Argument`);
return v;
}
function requireNumber(v: CellValue, ctx: string): number {
const n = toNumber(v);
if (isError(n)) throw new Error(`${ctx}: erwartet eine Zahl als Argument`);
return n;
}
/** Resolve a `SETVALUE` target (already `substitute()`d against the current
* `env`, mirroring how `execStatement`'s `'for'` case resolves its own
* source) into concrete cell coordinates to write to -- the write-side
* twin of `rangeOrCellToCellNodes`. A range target broadcasts the same
* value to every cell in it (Excel's Ctrl+Enter fill semantics). Unlike a
* normal read, an unqualified `qualifier === null` is a hard error here,
* never "this table" -- a macro has no home table (see
* store/document.ts#validateMacro, which already rejects this at save
* time; reaching it at runtime would mean a target came from a
* dynamically-substituted value the static check couldn't see).
*
* The `'namedref'` branch below is currently unreachable through any
* actually-parseable macro: `parseMacroBlock` turns off the bare-
* identifier named-range fallback, same as a custom-function body does
* and for the same reason (a typo'd local variable must fail loudly, not
* silently resolve to an unrelated document-wide named range) -- so a
* `NamedRefNode` can never appear in a macro's AST at all. Kept anyway,
* both as a defensive backstop and because it mirrors
* `rangeOrCellToCellNodes`'s identical shape below -- named-range support
* for macro targets is a documented, deliberate gap (see README's
* "Bekannte Grenzen"), not an oversight. */
function resolveWriteTarget(node: Node, host: EngineHost): CellRef[] {
let n = node;
if (n.kind === 'namedref') {
const resolved = resolveNamedRef(n, host);
if (isError(resolved)) throw new Error(`SETVALUE: unbekannter Name '${n.name}'`);
n = resolved;
}
if (n.kind === 'cell' || n.kind === 'range') {
if (n.qualifier == null) {
throw new Error('SETVALUE: Zellbezüge in Makros müssen die Tabelle nennen, z. B. Umsatz::A1');
}
const tableId = host.resolveTableName(n.qualifier, n.sheetQualifier ?? null);
if (!tableId) throw new Error(`SETVALUE: unbekannte Tabelle '${n.qualifier}'`);
if (n.kind === 'cell') return [{ tableId, col: n.col, row: n.row }];
return expandRange(n.start, n.end).map((c) => ({ tableId, col: c.col, row: c.row }));
}
throw new Error('SETVALUE: das erste Argument muss ein Zell- oder Bereichsbezug sein');
}
/** A FOR loop's source, once names are resolved against the current
* environment, must be a single cell, a range, or a named range -- never a
* computed value (mirrors "a range may only appear as a function argument"
+7 -1
View File
@@ -9,15 +9,18 @@ export { tokenize, LexError, type Token, type TokenType } from './lexer';
export {
parseFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
collectRefsInBlock,
ParseError,
type Node,
type CellNode,
type RangeNode,
type NamedRefNode,
type BlockNode,
type ActionNode,
} from './parser';
export { evaluate } from './evaluator';
export { evaluate, runMacroActions } from './evaluator';
export { DependencyGraph } from './graph';
export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
export {
@@ -47,10 +50,12 @@ export {
export {
buildFunctionIndex,
expandUserFunctions,
expandMacroBlock,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
findInvalidActionTarget,
blockAlwaysReturns,
type FunctionDef,
} from './userFunctions';
@@ -65,5 +70,6 @@ export {
type CellRef,
type Scalar,
type EngineHost,
type MacroWriteHost,
type NamedRangeDef,
} from './types';
+92 -15
View File
@@ -240,7 +240,20 @@ export interface IfNode {
then: BlockNode;
else: BlockNode | null;
}
export type Statement = AssignNode | ForNode | IfNode | ReturnNode;
/** `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
@@ -286,13 +299,21 @@ class Parser {
* 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 {
@@ -345,9 +366,43 @@ class Parser {
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}`);
@@ -528,19 +583,7 @@ class Parser {
// function call?
if (this.at('LPAREN')) {
this.next();
const args: Node[] = [];
// an argument slot with nothing in it (leading/middle/trailing `;`,
// right up against `;` or `)`) parses as an OmittedNode instead of
// failing -- see OmittedNode's doc comment for why.
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());
}
}
const args = this.parseArgList();
this.expect('RPAREN');
return { kind: 'func', name: t.value, args };
}
@@ -694,6 +737,29 @@ export function parseFunctionBlock(src: string, paramNames: readonly string[]):
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
@@ -732,7 +798,14 @@ export function collectRefs(
return out;
}
function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode | NamedRefNode>): void {
/** 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':
@@ -750,6 +823,10 @@ function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode |
collectRefsInBlock(stmt.then, out);
if (stmt.else) collectRefsInBlock(stmt.else, out);
break;
case 'action':
stmt.args.forEach((a) => collectRefs(a, out));
break;
}
}
return out;
}
+39
View File
@@ -140,3 +140,42 @@ export interface EngineHost {
*/
getComputed(tableId: string, col: number, row: number): CellValue;
}
/**
* The document's WRITE surface, as seen by a running macro (see
* evaluator.ts's `runMacroActions`/`execAction` and engine/README.md's
* "Makros" section). Deliberately separate from {@link EngineHost} -- that
* interface stays pure/read-only, implemented by `Workbook` alone, used by
* every ordinary formula evaluation; `MacroWriteHost` is only ever
* implemented by the store (`document.ts#runMacro`), only ever passed to
* the interpreter for an explicitly-triggered macro run, and never reaches
* the passive recalculation pass at all -- a macro's write is
* indistinguishable, from the engine's point of view, from the same edit
* made by hand through the UI.
*
* Every table/sheet argument below is a plain qualifier STRING (e.g.
* `"Umsatz"` or `"Blatt2!Kosten"`, split on the first `!`) rather than a
* resolved id or a dedicated reference AST node -- resolved fresh, by the
* implementation, exactly like a formula's own `Tabelle!Zelle` qualifier
* is resolved fresh on every read (never cached), so a macro that renames
* or adds a table earlier in its own run can address it by its new name
* later in the same run.
*/
export interface MacroWriteHost {
/** Write the same raw cell text (auto-detected as a formula if it starts
* with "=", exactly like typing it manually) into every given cell --
* one cell for a `SETVALUE` on a single reference, several for a range
* target (broadcasting the same value, Excel's Ctrl+Enter semantics). */
setValue(cells: CellRef[], text: string): void;
insertRow(tableRef: string, at: number): void;
deleteRow(tableRef: string, at: number): void;
insertCol(tableRef: string, at: number): void;
deleteCol(tableRef: string, at: number): void;
/** `sheetRef` is a plain sheet name (not table-qualified). */
addTable(sheetRef: string, name: string): void;
renameTable(tableRef: string, newName: string): void;
deleteTable(tableRef: string): void;
addSheet(name: string): void;
renameSheet(sheetRef: string, newName: string): void;
deleteSheet(sheetRef: string): void;
}
+54 -8
View File
@@ -31,6 +31,7 @@
import {
parseFormula,
parseFunctionBlock,
type ActionNode,
type BlockNode,
type ForNode,
type Node,
@@ -81,6 +82,23 @@ export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, Functi
return expand(ast, index, new Map(), new Map(), 0);
}
/**
* {@link expandUserFunctions}, for a macro body (`parseMacroBlock()`'s
* output) instead of an ordinary formula/function AST -- resolves any
* custom-function call reachable from a macro's expressions (e.g.
* `SETVALUE(Umsatz::A1; MARGE(Umsatz::B1; Umsatz::C1))`) the exact same way
* a formula would, via the already-existing `expandBlock`. The store calls
* this once per `runMacro()` before handing the block to
* evaluator.ts's `runMacroActions` -- a nested custom-function call can
* therefore compute a value inside a macro, but (since it's always parsed
* with `allowWrites: false`) can never itself contain an `ActionNode`, so
* it can never perform a write.
*/
export function expandMacroBlock(block: BlockNode, index: ReadonlyMap<string, FunctionDef>): BlockNode {
if (index.size === 0) return block; // same fast path as expandUserFunctions
return expandBlock(block, index, new Map(), new Map(), 0);
}
function expand(
node: Node,
index: ReadonlyMap<string, FunctionDef>,
@@ -235,6 +253,8 @@ function expandBlock(
then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
else: stmt.else ? expandBlock(stmt.else, index, bodyCache, blockCache, depth) : null,
};
case 'action':
return { ...stmt, args: stmt.args.map((a) => expand(a, index, bodyCache, blockCache, depth)) };
}
}),
};
@@ -289,6 +309,7 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
if (stmt.else) declaredBlockNames(stmt.else, out);
break;
case 'return':
case 'action':
break;
}
}
@@ -307,14 +328,7 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'for') {
if (
stmt.source.kind !== 'param' &&
stmt.source.kind !== 'cell' &&
stmt.source.kind !== 'range' &&
stmt.source.kind !== 'namedref'
) {
return stmt;
}
if (!isReferenceShaped(stmt.source)) return stmt;
const nested = findInvalidForSource(stmt.body);
if (nested) return nested;
} else if (stmt.kind === 'ifstmt') {
@@ -325,6 +339,35 @@ export function findInvalidForSource(block: BlockNode): ForNode | null {
return null;
}
/** Shared by {@link findInvalidForSource} and {@link findInvalidActionTarget}:
* can this node, once parameters/locals are substituted at call time,
* possibly resolve to a cell/range address at all? (A computed value --
* `2+2`, a function call, ... -- never can, no matter what's substituted
* in.) */
function isReferenceShaped(node: Node): boolean {
return node.kind === 'param' || node.kind === 'cell' || node.kind === 'range' || node.kind === 'namedref';
}
/** {@link findInvalidForSource}'s counterpart for a macro's `SETVALUE`
* target (its first argument) instead of a FOR source -- same reasoning,
* checked at save time by store/document.ts#validateMacro. */
export function findInvalidActionTarget(block: BlockNode): ActionNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'action') {
if (stmt.name.toUpperCase() === 'SETVALUE' && stmt.args.length > 0 && !isReferenceShaped(stmt.args[0])) {
return stmt;
}
} else if (stmt.kind === 'for') {
const nested = findInvalidActionTarget(stmt.body);
if (nested) return nested;
} else if (stmt.kind === 'ifstmt') {
const nested = findInvalidActionTarget(stmt.then) ?? (stmt.else && findInvalidActionTarget(stmt.else));
if (nested) return nested;
}
}
return null;
}
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
* over every leaf expression a `BlockNode` contains (each statement's
* value/condition/source, recursing into nested FOR/IF bodies) and merges
@@ -349,6 +392,9 @@ export function collectCalledFunctionNamesInBlock(
collectCalledFunctionNamesInBlock(stmt.then, out);
if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
break;
case 'action':
stmt.args.forEach((a) => collectCalledFunctionNames(a, out));
break;
}
}
return out;