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
+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;