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:
@@ -141,5 +141,6 @@ export function createSeedDocument(): DocumentState {
|
|||||||
// from a different sheet, without a table qualifier.
|
// from a different sheet, without a table qualifier.
|
||||||
{ id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } },
|
{ id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } },
|
||||||
],
|
],
|
||||||
|
macros: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
parseFormula,
|
parseFormula,
|
||||||
parseFunctionBlock,
|
parseFunctionBlock,
|
||||||
|
parseMacroBlock,
|
||||||
collectRefs,
|
collectRefs,
|
||||||
|
collectRefsInBlock,
|
||||||
ParseError,
|
ParseError,
|
||||||
type Node,
|
type Node,
|
||||||
type Statement,
|
type Statement,
|
||||||
@@ -54,6 +56,8 @@ function sexprStmt(s: Statement): string {
|
|||||||
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
|
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
|
||||||
case 'ifstmt':
|
case 'ifstmt':
|
||||||
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
|
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 {
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseFormula } from '../parser';
|
import { parseFormula, parseMacroBlock } from '../parser';
|
||||||
import {
|
import {
|
||||||
buildFunctionIndex,
|
buildFunctionIndex,
|
||||||
collectCalledFunctionNames,
|
collectCalledFunctionNames,
|
||||||
|
collectCalledFunctionNamesInBlock,
|
||||||
|
expandMacroBlock,
|
||||||
expandUserFunctions,
|
expandUserFunctions,
|
||||||
type FunctionDef,
|
type FunctionDef,
|
||||||
} from '../userFunctions';
|
} 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', () => {
|
describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||||
function fixture(functionDefs: FunctionDef[] = []): Workbook {
|
function fixture(functionDefs: FunctionDef[] = []): Workbook {
|
||||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||||
|
|||||||
+181
-11
@@ -39,11 +39,11 @@
|
|||||||
* ordinary function call this can't be resolved any earlier.
|
* 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 { getFunction, isTruthy } from './functions';
|
||||||
import { expandRange } from './refs';
|
import { expandRange } from './refs';
|
||||||
import { substitute } from './userFunctions';
|
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 {
|
export function evaluate(ast: Node, host: EngineHost): CellValue {
|
||||||
return evalScalar(ast, host);
|
return evalScalar(ast, host);
|
||||||
@@ -345,9 +345,19 @@ class ReturnSignal {
|
|||||||
* either way. Generous for anything this app's tables realistically hold. */
|
* either way. Generous for anything this app's tables realistically hold. */
|
||||||
const MAX_LOOP_STEPS = 10_000;
|
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 {
|
function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
|
||||||
const env = new Map<string, Node>(node.paramNames.map((p, i) => [p.toUpperCase(), node.args[i]]));
|
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 {
|
try {
|
||||||
execBlock(node.block, env, host, budget);
|
execBlock(node.block, env, host, budget);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -359,9 +369,41 @@ function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
|
|||||||
throw new Error(`'${node.label}' ended without RETURN`);
|
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) {
|
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,
|
stmt: Statement,
|
||||||
env: Map<string, Node>,
|
env: Map<string, Node>,
|
||||||
host: EngineHost,
|
host: EngineHost,
|
||||||
budget: { steps: number },
|
budget: Budget,
|
||||||
|
writeHost?: MacroWriteHost,
|
||||||
): void {
|
): void {
|
||||||
switch (stmt.kind) {
|
switch (stmt.kind) {
|
||||||
case 'assign': {
|
case 'assign': {
|
||||||
@@ -404,8 +447,8 @@ function execStatement(
|
|||||||
case 'ifstmt': {
|
case 'ifstmt': {
|
||||||
const c = evalInEnv(stmt.cond, env, host);
|
const c = evalInEnv(stmt.cond, env, host);
|
||||||
if (isError(c)) throw new ReturnSignal(c);
|
if (isError(c)) throw new ReturnSignal(c);
|
||||||
if (isTruthy(c)) execBlock(stmt.then, env, host, budget);
|
if (isTruthy(c)) execBlock(stmt.then, env, host, budget, writeHost);
|
||||||
else if (stmt.else) execBlock(stmt.else, env, host, budget);
|
else if (stmt.else) execBlock(stmt.else, env, host, budget, writeHost);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'for': {
|
case 'for': {
|
||||||
@@ -413,15 +456,142 @@ function execStatement(
|
|||||||
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
|
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
budget.steps++;
|
budget.steps++;
|
||||||
if (budget.steps > MAX_LOOP_STEPS) {
|
if (budget.steps > budget.limit) {
|
||||||
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${MAX_LOOP_STEPS} insgesamt)`);
|
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${budget.limit} insgesamt)`);
|
||||||
}
|
}
|
||||||
env.set(stmt.varName.toUpperCase(), cell);
|
env.set(stmt.varName.toUpperCase(), cell);
|
||||||
execBlock(stmt.body, env, host, budget);
|
execBlock(stmt.body, env, host, budget, writeHost);
|
||||||
}
|
}
|
||||||
return;
|
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
|
/** A FOR loop's source, once names are resolved against the current
|
||||||
|
|||||||
+7
-1
@@ -9,15 +9,18 @@ export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
|||||||
export {
|
export {
|
||||||
parseFormula,
|
parseFormula,
|
||||||
parseFunctionBlock,
|
parseFunctionBlock,
|
||||||
|
parseMacroBlock,
|
||||||
collectRefs,
|
collectRefs,
|
||||||
|
collectRefsInBlock,
|
||||||
ParseError,
|
ParseError,
|
||||||
type Node,
|
type Node,
|
||||||
type CellNode,
|
type CellNode,
|
||||||
type RangeNode,
|
type RangeNode,
|
||||||
type NamedRefNode,
|
type NamedRefNode,
|
||||||
type BlockNode,
|
type BlockNode,
|
||||||
|
type ActionNode,
|
||||||
} from './parser';
|
} from './parser';
|
||||||
export { evaluate } from './evaluator';
|
export { evaluate, runMacroActions } from './evaluator';
|
||||||
export { DependencyGraph } from './graph';
|
export { DependencyGraph } from './graph';
|
||||||
export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
|
export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
|
||||||
export {
|
export {
|
||||||
@@ -47,10 +50,12 @@ export {
|
|||||||
export {
|
export {
|
||||||
buildFunctionIndex,
|
buildFunctionIndex,
|
||||||
expandUserFunctions,
|
expandUserFunctions,
|
||||||
|
expandMacroBlock,
|
||||||
collectCalledFunctionNames,
|
collectCalledFunctionNames,
|
||||||
collectCalledFunctionNamesInBlock,
|
collectCalledFunctionNamesInBlock,
|
||||||
declaredBlockNames,
|
declaredBlockNames,
|
||||||
findInvalidForSource,
|
findInvalidForSource,
|
||||||
|
findInvalidActionTarget,
|
||||||
blockAlwaysReturns,
|
blockAlwaysReturns,
|
||||||
type FunctionDef,
|
type FunctionDef,
|
||||||
} from './userFunctions';
|
} from './userFunctions';
|
||||||
@@ -65,5 +70,6 @@ export {
|
|||||||
type CellRef,
|
type CellRef,
|
||||||
type Scalar,
|
type Scalar,
|
||||||
type EngineHost,
|
type EngineHost,
|
||||||
|
type MacroWriteHost,
|
||||||
type NamedRangeDef,
|
type NamedRangeDef,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
+92
-15
@@ -240,7 +240,20 @@ export interface IfNode {
|
|||||||
then: BlockNode;
|
then: BlockNode;
|
||||||
else: BlockNode | null;
|
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
|
/** A sequence of statements -- a whole function body, or the body of a
|
||||||
* FOR/IF. Every name a statement introduces (assignment target or loop
|
* FOR/IF. Every name a statement introduces (assignment target or loop
|
||||||
* variable) stays visible for the *rest* of the execution, including after
|
* 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
|
* branch and parseFormula()'s doc comment for why function bodies opt
|
||||||
* out of the fallback. */
|
* out of the fallback. */
|
||||||
private readonly namedRefFallback: boolean;
|
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(
|
constructor(
|
||||||
private readonly toks: Token[],
|
private readonly toks: Token[],
|
||||||
paramNames: readonly string[] = [],
|
paramNames: readonly string[] = [],
|
||||||
namedRefFallback = true,
|
namedRefFallback = true,
|
||||||
|
allowWrites = false,
|
||||||
) {
|
) {
|
||||||
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
||||||
this.namedRefFallback = namedRefFallback;
|
this.namedRefFallback = namedRefFallback;
|
||||||
|
this.allowWrites = allowWrites;
|
||||||
}
|
}
|
||||||
|
|
||||||
private peek(): Token {
|
private peek(): Token {
|
||||||
@@ -345,9 +366,43 @@ class Parser {
|
|||||||
if (this.isKeyword('FOR')) return this.parseForLoop();
|
if (this.isKeyword('FOR')) return this.parseForLoop();
|
||||||
if (this.isKeyword('IF')) return this.parseIfStmt();
|
if (this.isKeyword('IF')) return this.parseIfStmt();
|
||||||
if (this.isKeyword('RETURN')) return this.parseReturnStmt();
|
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();
|
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 {
|
private parseAssignment(): AssignNode {
|
||||||
if (!this.at('IDENT')) {
|
if (!this.at('IDENT')) {
|
||||||
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
|
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
|
||||||
@@ -528,19 +583,7 @@ class Parser {
|
|||||||
// function call?
|
// function call?
|
||||||
if (this.at('LPAREN')) {
|
if (this.at('LPAREN')) {
|
||||||
this.next();
|
this.next();
|
||||||
const args: Node[] = [];
|
const args = this.parseArgList();
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.expect('RPAREN');
|
this.expect('RPAREN');
|
||||||
return { kind: 'func', name: t.value, args };
|
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();
|
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
|
/** Walk an AST and collect every cell/range/named-range reference node it
|
||||||
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
||||||
* every expression reachable inside its `block` -- conservatively: a
|
* every expression reachable inside its `block` -- conservatively: a
|
||||||
@@ -732,7 +798,14 @@ export function collectRefs(
|
|||||||
return out;
|
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) {
|
for (const stmt of block.statements) {
|
||||||
switch (stmt.kind) {
|
switch (stmt.kind) {
|
||||||
case 'assign':
|
case 'assign':
|
||||||
@@ -750,6 +823,10 @@ function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode |
|
|||||||
collectRefsInBlock(stmt.then, out);
|
collectRefsInBlock(stmt.then, out);
|
||||||
if (stmt.else) collectRefsInBlock(stmt.else, out);
|
if (stmt.else) collectRefsInBlock(stmt.else, out);
|
||||||
break;
|
break;
|
||||||
|
case 'action':
|
||||||
|
stmt.args.forEach((a) => collectRefs(a, out));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,3 +140,42 @@ export interface EngineHost {
|
|||||||
*/
|
*/
|
||||||
getComputed(tableId: string, col: number, row: number): CellValue;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
import {
|
import {
|
||||||
parseFormula,
|
parseFormula,
|
||||||
parseFunctionBlock,
|
parseFunctionBlock,
|
||||||
|
type ActionNode,
|
||||||
type BlockNode,
|
type BlockNode,
|
||||||
type ForNode,
|
type ForNode,
|
||||||
type Node,
|
type Node,
|
||||||
@@ -81,6 +82,23 @@ export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, Functi
|
|||||||
return expand(ast, index, new Map(), new Map(), 0);
|
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(
|
function expand(
|
||||||
node: Node,
|
node: Node,
|
||||||
index: ReadonlyMap<string, FunctionDef>,
|
index: ReadonlyMap<string, FunctionDef>,
|
||||||
@@ -235,6 +253,8 @@ function expandBlock(
|
|||||||
then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
|
then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
|
||||||
else: stmt.else ? expandBlock(stmt.else, index, bodyCache, blockCache, depth) : null,
|
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);
|
if (stmt.else) declaredBlockNames(stmt.else, out);
|
||||||
break;
|
break;
|
||||||
case 'return':
|
case 'return':
|
||||||
|
case 'action':
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,14 +328,7 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
|
|||||||
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||||
for (const stmt of block.statements) {
|
for (const stmt of block.statements) {
|
||||||
if (stmt.kind === 'for') {
|
if (stmt.kind === 'for') {
|
||||||
if (
|
if (!isReferenceShaped(stmt.source)) return stmt;
|
||||||
stmt.source.kind !== 'param' &&
|
|
||||||
stmt.source.kind !== 'cell' &&
|
|
||||||
stmt.source.kind !== 'range' &&
|
|
||||||
stmt.source.kind !== 'namedref'
|
|
||||||
) {
|
|
||||||
return stmt;
|
|
||||||
}
|
|
||||||
const nested = findInvalidForSource(stmt.body);
|
const nested = findInvalidForSource(stmt.body);
|
||||||
if (nested) return nested;
|
if (nested) return nested;
|
||||||
} else if (stmt.kind === 'ifstmt') {
|
} else if (stmt.kind === 'ifstmt') {
|
||||||
@@ -325,6 +339,35 @@ export function findInvalidForSource(block: BlockNode): ForNode | null {
|
|||||||
return 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
|
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
|
||||||
* over every leaf expression a `BlockNode` contains (each statement's
|
* over every leaf expression a `BlockNode` contains (each statement's
|
||||||
* value/condition/source, recursing into nested FOR/IF bodies) and merges
|
* value/condition/source, recursing into nested FOR/IF bodies) and merges
|
||||||
@@ -349,6 +392,9 @@ export function collectCalledFunctionNamesInBlock(
|
|||||||
collectCalledFunctionNamesInBlock(stmt.then, out);
|
collectCalledFunctionNamesInBlock(stmt.then, out);
|
||||||
if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
|
if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
|
||||||
break;
|
break;
|
||||||
|
case 'action':
|
||||||
|
stmt.args.forEach((a) => collectCalledFunctionNames(a, out));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
CustomFunction,
|
CustomFunction,
|
||||||
DocumentState,
|
DocumentState,
|
||||||
HAlign,
|
HAlign,
|
||||||
|
Macro,
|
||||||
NamedRange,
|
NamedRange,
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetObject,
|
SheetObject,
|
||||||
@@ -100,6 +101,32 @@ function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet<string>): NamedR
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||||
|
* existed. Unlike a named range, a macro carries no `tableId`/other
|
||||||
|
* document-internal reference to validate against -- its body is a plain
|
||||||
|
* string, parsed and validated lazily (store/document.ts's `validateMacro`)
|
||||||
|
* only when actually edited/run, same lenient convention as a custom
|
||||||
|
* function's `body` (an import doesn't re-run every save-time check, only
|
||||||
|
* shape validation). */
|
||||||
|
function normaliseMacros(x: unknown): Macro[] {
|
||||||
|
if (!Array.isArray(x)) return [];
|
||||||
|
const out: Macro[] = [];
|
||||||
|
for (const m of x) {
|
||||||
|
if (
|
||||||
|
!m ||
|
||||||
|
typeof m !== 'object' ||
|
||||||
|
typeof (m as { id?: unknown }).id !== 'string' ||
|
||||||
|
typeof (m as { name?: unknown }).name !== 'string' ||
|
||||||
|
typeof (m as { body?: unknown }).body !== 'string'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rec = m as Macro;
|
||||||
|
out.push({ id: rec.id, name: rec.name, body: rec.body });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||||
function colorOrUndef(x: unknown): string | undefined {
|
function colorOrUndef(x: unknown): string | undefined {
|
||||||
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
||||||
@@ -375,6 +402,7 @@ export function validateDocument(x: unknown): DocumentState {
|
|||||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||||
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||||
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
|
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
|
||||||
|
macros: normaliseMacros(doc.macros),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +467,7 @@ export function migrateToV2(x: unknown): DocumentState {
|
|||||||
csvDelimiter: ',',
|
csvDelimiter: ',',
|
||||||
customFunctions: [],
|
customFunctions: [],
|
||||||
namedRanges: [],
|
namedRanges: [],
|
||||||
|
macros: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1402,6 +1402,215 @@ describe('functionSuggestions (formula autocomplete)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||||
|
it('addMacro stores the definition; a bare (unqualified) cell reference is rejected at save time', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const bad = s.addMacro('M', 'SETVALUE(A1; 1)') as { error: string };
|
||||||
|
expect(bad.error).toBeTruthy();
|
||||||
|
expect(s.doc.macros).toHaveLength(0);
|
||||||
|
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const good = s.addMacro('M', `SETVALUE(${name}::A1; 1)`);
|
||||||
|
expect(typeof good).toBe('string');
|
||||||
|
expect(s.doc.macros).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a name colliding with another macro (case-insensitive)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
s.addMacro('Setup', `SETVALUE(${name}::A1; 1)`);
|
||||||
|
const result = s.addMacro('SETUP', `SETVALUE(${name}::A1; 2)`) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(s.doc.macros).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects one of the 11 write-action names used as a local variable', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const result = s.addMacro('M', `SETVALUE = 1 SETVALUE(${name}::A1; SETVALUE)`) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a FOR source or SETVALUE target that is a computed expression, not a real reference', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
expect((s.addMacro('M1', `SETVALUE(1+1; 1)`) as { error: string }).error).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
(s.addMacro('M2', `FOR c IN 1+1 DO SETVALUE(${name}::A1; 0) END`) as { error: string }).error,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string;
|
||||||
|
|
||||||
|
const result = s.runMacro(macroId);
|
||||||
|
expect(result).toEqual({ ok: true, message: 'Makro ausgeführt.' });
|
||||||
|
expect(s.cellInput(id, 0, 0)).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: a RETURNed string becomes the success message instead of the default', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string;
|
||||||
|
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'fertig' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '3');
|
||||||
|
setCell(s, name, 'A2', '4');
|
||||||
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string;
|
||||||
|
expect(s.runMacro(macroId).ok).toBe(true);
|
||||||
|
expect(s.cellInput(id, 0, 2)).toBe('=3+4');
|
||||||
|
expect(val(s, name, 'A3')).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '10');
|
||||||
|
setCell(s, name, 'A2', '20');
|
||||||
|
setCell(s, name, 'B1', `=${name}::A1+${name}::A2`);
|
||||||
|
const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string;
|
||||||
|
|
||||||
|
expect(s.runMacro(macroId).ok).toBe(true);
|
||||||
|
expect(s.tableById(id)!.rows).toBe(13); // seed default 12 + 1
|
||||||
|
expect(s.cellInput(id, 0, 1)).toBe('10'); // A1's content slid down to A2
|
||||||
|
expect(s.cellInput(id, 1, 1)).toBe(`=${name}::A2+${name}::A3`); // the SUM formula's own refs shifted too
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '5');
|
||||||
|
// SETVALUE writes B1, then INSERTROW at 0 shifts everything down one row
|
||||||
|
// (B1's write becomes B2), then a second SETVALUE writes C1 based on a
|
||||||
|
// READ of A1 (now at A2, post-insert) -- if the interpreter's host were
|
||||||
|
// a stale cached Workbook reference from before the INSERTROW, this
|
||||||
|
// read would silently return an empty/wrong value instead of 5.
|
||||||
|
const macroId = s.addMacro(
|
||||||
|
'M',
|
||||||
|
`SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`,
|
||||||
|
) as string;
|
||||||
|
|
||||||
|
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'Makro ausgeführt.' });
|
||||||
|
expect(s.cellInput(id, 1, 1)).toBe('1'); // B1's write, shifted to B2 by the insert
|
||||||
|
expect(val(s, name, 'C1')).toBe(105); // reads A2 (=5, shifted from A1) fresh, post-insert
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const sheetId = s.doc.activeSheetId;
|
||||||
|
const sheetName = s.sheetById(sheetId)!.name;
|
||||||
|
const macroId = s.addMacro(
|
||||||
|
'M',
|
||||||
|
`ADDTABLE("${sheetName}"; "MakroTabelle") RENAMETABLE("MakroTabelle"; "Umbenannt") DELETETABLE("Umbenannt")`,
|
||||||
|
) as string;
|
||||||
|
const before = s.tablesOf(sheetId).length;
|
||||||
|
|
||||||
|
expect(s.runMacro(macroId).ok).toBe(true);
|
||||||
|
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const before = s.doc.sheets.length;
|
||||||
|
const macroId = s.addMacro(
|
||||||
|
'M',
|
||||||
|
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
|
||||||
|
) as string;
|
||||||
|
|
||||||
|
expect(s.runMacro(macroId).ok).toBe(true);
|
||||||
|
expect(s.doc.sheets.length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', () => {
|
||||||
|
const s = fresh();
|
||||||
|
// collapse to exactly one sheet first
|
||||||
|
while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id);
|
||||||
|
const onlySheetName = s.doc.sheets[0].name;
|
||||||
|
const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string;
|
||||||
|
|
||||||
|
const result = s.runMacro(macroId);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(s.doc.sheets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: an unknown table reference fails cleanly, no throw escapes', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string;
|
||||||
|
const result = s.runMacro(macroId);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toMatch(/unbekannte Tabelle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runMacro: an unknown macro id fails cleanly', () => {
|
||||||
|
const s = fresh();
|
||||||
|
expect(s.runMacro('does-not-exist').ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateMacro changes the body; deleteMacro removes it', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string;
|
||||||
|
|
||||||
|
s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`);
|
||||||
|
s.runMacro(macroId);
|
||||||
|
expect(s.cellInput(id, 0, 0)).toBe('2');
|
||||||
|
|
||||||
|
s.deleteMacro(macroId);
|
||||||
|
expect(s.doc.macros).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string;
|
||||||
|
vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first
|
||||||
|
|
||||||
|
s.runMacro(macroId);
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert
|
||||||
|
expect(s.tableById(id)!.rows).toBe(13);
|
||||||
|
|
||||||
|
s.undo(); // one Ctrl+Z undoes the ENTIRE macro run
|
||||||
|
expect(s.cellInput(id, 0, 0)).toBe('');
|
||||||
|
expect(s.cellInput(id, 0, 1)).toBe('');
|
||||||
|
expect(s.tableById(id)!.rows).toBe(12);
|
||||||
|
} finally {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON export/import round-trips macros byte-for-byte', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
s.addMacro('Meine Aktion', `SETVALUE(${name}::A1; 1)`);
|
||||||
|
|
||||||
|
const json = serializeDocument(s.doc);
|
||||||
|
const reimported = parseDocument(json);
|
||||||
|
expect(reimported.macros).toEqual(s.doc.macros);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
|
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
|
||||||
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ import {
|
|||||||
parseA1,
|
parseA1,
|
||||||
parseFormula,
|
parseFormula,
|
||||||
parseFunctionBlock,
|
parseFunctionBlock,
|
||||||
|
parseMacroBlock,
|
||||||
|
collectRefsInBlock,
|
||||||
hasFunction,
|
hasFunction,
|
||||||
collectCalledFunctionNames,
|
collectCalledFunctionNames,
|
||||||
collectCalledFunctionNamesInBlock,
|
collectCalledFunctionNamesInBlock,
|
||||||
declaredBlockNames,
|
declaredBlockNames,
|
||||||
findInvalidForSource,
|
findInvalidForSource,
|
||||||
|
findInvalidActionTarget,
|
||||||
blockAlwaysReturns,
|
blockAlwaysReturns,
|
||||||
|
buildFunctionIndex,
|
||||||
|
expandMacroBlock,
|
||||||
|
runMacroActions,
|
||||||
parseDateTimeLiteral,
|
parseDateTimeLiteral,
|
||||||
BUILTIN_SIGNATURES,
|
BUILTIN_SIGNATURES,
|
||||||
type CellRef,
|
type CellRef,
|
||||||
@@ -41,6 +47,8 @@ import {
|
|||||||
type Token,
|
type Token,
|
||||||
type FunctionDef,
|
type FunctionDef,
|
||||||
type NamedRangeDef,
|
type NamedRangeDef,
|
||||||
|
type MacroWriteHost,
|
||||||
|
type EngineHost,
|
||||||
} from '../engine';
|
} from '../engine';
|
||||||
import {
|
import {
|
||||||
formatValue,
|
formatValue,
|
||||||
@@ -57,6 +65,7 @@ import type {
|
|||||||
CustomFunction,
|
CustomFunction,
|
||||||
DocumentState,
|
DocumentState,
|
||||||
FnSuggestion,
|
FnSuggestion,
|
||||||
|
Macro,
|
||||||
Sheet,
|
Sheet,
|
||||||
SelectionState,
|
SelectionState,
|
||||||
SheetObject,
|
SheetObject,
|
||||||
@@ -498,6 +507,7 @@ function createStore() {
|
|||||||
csvDelimiter: d.csvDelimiter,
|
csvDelimiter: d.csvDelimiter,
|
||||||
customFunctions: d.customFunctions,
|
customFunctions: d.customFunctions,
|
||||||
namedRanges: d.namedRanges,
|
namedRanges: d.namedRanges,
|
||||||
|
macros: d.macros,
|
||||||
});
|
});
|
||||||
let historySnapshot = snapshot();
|
let historySnapshot = snapshot();
|
||||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||||
@@ -626,6 +636,12 @@ function createStore() {
|
|||||||
|
|
||||||
// ---- lookups --------------------------------------------------
|
// ---- lookups --------------------------------------------------
|
||||||
const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id);
|
const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id);
|
||||||
|
/** Case-insensitive/trimmed lookup by *name* -- unlike `sheetById`, needed
|
||||||
|
* for a macro's plain-string sheet references (`ADDSHEET`, `RENAMESHEET`,
|
||||||
|
* `DELETESHEET`; see `runMacro` below), which address a sheet the same
|
||||||
|
* way a formula's own qualifier addresses a table: by name, not id. */
|
||||||
|
const sheetByName = (name: string): Sheet | undefined =>
|
||||||
|
doc.sheets.find((s) => s.name.trim().toLowerCase() === name.trim().toLowerCase());
|
||||||
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
|
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
|
||||||
|
|
||||||
function objectById(id: string | null): SheetObject | undefined {
|
function objectById(id: string | null): SheetObject | undefined {
|
||||||
@@ -930,6 +946,27 @@ function createStore() {
|
|||||||
* later switched to "Mit Schleife"). */
|
* later switched to "Mit Schleife"). */
|
||||||
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
||||||
|
|
||||||
|
/** The macro write-action vocabulary (see engine/types.ts's
|
||||||
|
* `MacroWriteHost` and evaluator.ts's `execAction`) -- reserved against
|
||||||
|
* use as a macro-local variable/loop-variable name, same reasoning as
|
||||||
|
* `RESERVED_NAMES`: `SETVALUE = 5` would parse under the grammar (an
|
||||||
|
* ordinary assignment, no ambiguity) but reads as if it might be calling
|
||||||
|
* the action, so it's rejected at save time rather than left as a
|
||||||
|
* confusing-but-technically-legal trap. */
|
||||||
|
const MACRO_ACTION_NAMES = new Set([
|
||||||
|
'SETVALUE',
|
||||||
|
'INSERTROW',
|
||||||
|
'DELETEROW',
|
||||||
|
'INSERTCOL',
|
||||||
|
'DELETECOL',
|
||||||
|
'ADDTABLE',
|
||||||
|
'RENAMETABLE',
|
||||||
|
'DELETETABLE',
|
||||||
|
'ADDSHEET',
|
||||||
|
'RENAMESHEET',
|
||||||
|
'DELETESHEET',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full validation for add/update: name & parameter shape, no collision with
|
* Full validation for add/update: name & parameter shape, no collision with
|
||||||
* a built-in or another custom function, the body parses (as an
|
* a built-in or another custom function, the body parses (as an
|
||||||
@@ -1166,6 +1203,192 @@ function createStore() {
|
|||||||
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
|
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- macros (document-level, explicitly-triggered write scripts) ------
|
||||||
|
// Created/edited/removed exclusively through the "▶ Makros" UI, run via
|
||||||
|
// that panel's own "▶ Ausführen" button or a canvas button object's click
|
||||||
|
// -- NEVER callable from a cell formula (see store/types.ts's `Macro` doc
|
||||||
|
// comment for why this is a separate type from `CustomFunction`, not a
|
||||||
|
// third `kind`, so that's impossible by construction). Unlike a custom
|
||||||
|
// function's body, every cell/range reference in a macro must be
|
||||||
|
// qualified with a table name (`Umsatz::A1`, never bare `A1`) -- a macro
|
||||||
|
// has no "home table" the way a formula cell does.
|
||||||
|
function validateMacro(name: string, body: string, ignoreId: string | null): string | null {
|
||||||
|
const cleanName = name.trim();
|
||||||
|
if (!IDENT_RE.test(cleanName)) {
|
||||||
|
return `"${name}" ist kein gültiger Makroname.`;
|
||||||
|
}
|
||||||
|
if (looksLikeCell(cleanName)) {
|
||||||
|
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Makroname nicht erlaubt.`;
|
||||||
|
}
|
||||||
|
const collision = doc.macros.find(
|
||||||
|
(m) => m.id !== ignoreId && m.name.toLowerCase() === cleanName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (collision) return `Es gibt bereits ein Makro namens "${collision.name}".`;
|
||||||
|
|
||||||
|
let block;
|
||||||
|
try {
|
||||||
|
block = parseMacroBlock(body);
|
||||||
|
} catch (e) {
|
||||||
|
return `Makro ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
const localNames = declaredBlockNames(block);
|
||||||
|
for (const n of localNames) {
|
||||||
|
if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`;
|
||||||
|
if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||||||
|
if (RESERVED_NAMES.has(n.toUpperCase())) {
|
||||||
|
return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||||||
|
}
|
||||||
|
if (MACRO_ACTION_NAMES.has(n.toUpperCase())) {
|
||||||
|
return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const badFor = findInvalidForSource(block);
|
||||||
|
if (badFor) {
|
||||||
|
return `FOR ${badFor.varName} IN ...: die Quelle muss eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||||||
|
}
|
||||||
|
const badAction = findInvalidActionTarget(block);
|
||||||
|
if (badAction) {
|
||||||
|
return `${badAction.name}: das erste Argument muss ein Zell- oder Bereichsbezug sein, kein berechneter Ausdruck.`;
|
||||||
|
}
|
||||||
|
for (const ref of collectRefsInBlock(block)) {
|
||||||
|
if ((ref.kind === 'cell' || ref.kind === 'range') && ref.qualifier === null) {
|
||||||
|
return 'Zellbezüge in Makros müssen die Tabelle nennen, z. B. Umsatz::A1.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMacro(name: string, body: string): string | { error: string } {
|
||||||
|
const error = validateMacro(name, body, null);
|
||||||
|
if (error) return { error };
|
||||||
|
const id = uid('mac');
|
||||||
|
const macro: Macro = { id, name: name.trim(), body: body.startsWith('=') ? body.slice(1) : body };
|
||||||
|
doc.macros.push(macro);
|
||||||
|
bump();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMacro(id: string, name: string, body: string): void | { error: string } {
|
||||||
|
const m = doc.macros.find((x) => x.id === id);
|
||||||
|
if (!m) return { error: 'Makro nicht gefunden.' };
|
||||||
|
const error = validateMacro(name, body, id);
|
||||||
|
if (error) return { error };
|
||||||
|
m.name = name.trim();
|
||||||
|
m.body = body.startsWith('=') ? body.slice(1) : body;
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteMacro(id: string) {
|
||||||
|
doc.macros = doc.macros.filter((m) => m.id !== id);
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a macro action's plain "Tabelle" / "Blatt!Tabelle" string
|
||||||
|
* argument the same way a formula's own qualifier resolves -- via the
|
||||||
|
* live `wb.resolveTableName`. Reads the outer (closure-scoped) `wb`
|
||||||
|
* directly, so it always sees whatever `syncWorkbook()` most recently
|
||||||
|
* assigned -- critical when an earlier action in the SAME macro run was
|
||||||
|
* structural (see `runMacro`'s own doc comment). Throws a clear error
|
||||||
|
* when the table doesn't exist, instead of the primitives' own silent
|
||||||
|
* no-op on an unknown id. */
|
||||||
|
function resolveMacroTableRef(ref: string): string {
|
||||||
|
const bang = ref.indexOf('!');
|
||||||
|
const table = bang === -1 ? ref : ref.slice(bang + 1);
|
||||||
|
const sheet = bang === -1 ? null : ref.slice(0, bang);
|
||||||
|
const tableId = wb.resolveTableName(table, sheet);
|
||||||
|
if (!tableId) throw new Error(`unbekannte Tabelle '${ref}'`);
|
||||||
|
return tableId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a macro synchronously to completion (see store/types.ts's `Macro`
|
||||||
|
* doc comment for the "why explicit trigger, not recalc-embedded" design)
|
||||||
|
* -- a full JS-synchronous run automatically becomes exactly ONE undo
|
||||||
|
* step, for free, via the existing debounced-fingerprint history (no
|
||||||
|
* transaction machinery needed, see `commitHistoryStep`'s doc comment).
|
||||||
|
*
|
||||||
|
* Both hosts handed to the interpreter re-read the outer `wb` on every
|
||||||
|
* call rather than capturing it once at the top -- required correctness,
|
||||||
|
* not a style choice: a structural action (INSERTROW, ADDTABLE, ...)
|
||||||
|
* calls `syncWorkbook()`, which reassigns `wb` to a brand-new `Workbook`
|
||||||
|
* instance; a cached reference from before that point would silently go
|
||||||
|
* stale for every read for the rest of the run.
|
||||||
|
*/
|
||||||
|
function runMacro(macroId: string): { ok: boolean; message: string } {
|
||||||
|
const macro = doc.macros.find((m) => m.id === macroId);
|
||||||
|
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' };
|
||||||
|
|
||||||
|
const macroHost: EngineHost = {
|
||||||
|
// Never actually consulted: validateMacro rejects every unqualified
|
||||||
|
// cell/range reference at save time, so evalScalar's 'cell' case
|
||||||
|
// never needs "this table" for a macro. A getter (not a fixed empty
|
||||||
|
// string) purely so this object has the exact same "always reads
|
||||||
|
// fresh" shape as every other field here.
|
||||||
|
get homeTableId() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
resolveTableName: (name, sheetName) => wb.resolveTableName(name, sheetName ?? null),
|
||||||
|
resolveNamedRange: (name) => wb.resolveNamedRange(name),
|
||||||
|
tableSize: (id) => wb.tableSize(id),
|
||||||
|
getComputed: (tableId, col, row) => wb.getComputed(tableId, col, row),
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeHost: MacroWriteHost = {
|
||||||
|
setValue(cells, text) {
|
||||||
|
for (const c of cells) setCellInput(c.tableId, c.col, c.row, text);
|
||||||
|
},
|
||||||
|
insertRow(tableRef, at) {
|
||||||
|
insertRow(resolveMacroTableRef(tableRef), at);
|
||||||
|
},
|
||||||
|
deleteRow(tableRef, at) {
|
||||||
|
deleteRow(resolveMacroTableRef(tableRef), at);
|
||||||
|
},
|
||||||
|
insertCol(tableRef, at) {
|
||||||
|
insertColumn(resolveMacroTableRef(tableRef), at);
|
||||||
|
},
|
||||||
|
deleteCol(tableRef, at) {
|
||||||
|
deleteColumn(resolveMacroTableRef(tableRef), at);
|
||||||
|
},
|
||||||
|
addTable(sheetRef, name) {
|
||||||
|
const sheet = sheetByName(sheetRef);
|
||||||
|
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||||||
|
renameObject(addTable(sheet.id), name);
|
||||||
|
},
|
||||||
|
renameTable(tableRef, newName) {
|
||||||
|
renameObject(resolveMacroTableRef(tableRef), newName);
|
||||||
|
},
|
||||||
|
deleteTable(tableRef) {
|
||||||
|
deleteObject(resolveMacroTableRef(tableRef));
|
||||||
|
},
|
||||||
|
addSheet(name) {
|
||||||
|
addSheet(name);
|
||||||
|
},
|
||||||
|
renameSheet(sheetRef, newName) {
|
||||||
|
const sheet = sheetByName(sheetRef);
|
||||||
|
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||||||
|
renameSheet(sheet.id, newName);
|
||||||
|
},
|
||||||
|
deleteSheet(sheetRef) {
|
||||||
|
const sheet = sheetByName(sheetRef);
|
||||||
|
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||||||
|
// deleteSheet() itself silently no-ops on the last sheet -- surface
|
||||||
|
// that as a real failure here instead of a misleading success toast.
|
||||||
|
if (doc.sheets.length <= 1) throw new Error('das letzte Blatt kann nicht gelöscht werden');
|
||||||
|
deleteSheet(sheet.id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const block = parseMacroBlock(macro.body);
|
||||||
|
const index = buildFunctionIndex(toFunctionDefs(doc));
|
||||||
|
const expanded = expandMacroBlock(block, index);
|
||||||
|
const result = runMacroActions(expanded, macroHost, writeHost);
|
||||||
|
return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function moveObject(objId: string, x: number, y: number) {
|
function moveObject(objId: string, x: number, y: number) {
|
||||||
const o = objectById(objId);
|
const o = objectById(objId);
|
||||||
if (!o) return;
|
if (!o) return;
|
||||||
@@ -2109,6 +2332,7 @@ function createStore() {
|
|||||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||||
doc.customFunctions = next.customFunctions ?? [];
|
doc.customFunctions = next.customFunctions ?? [];
|
||||||
doc.namedRanges = next.namedRanges ?? [];
|
doc.namedRanges = next.namedRanges ?? [];
|
||||||
|
doc.macros = next.macros ?? [];
|
||||||
syncWorkbook();
|
syncWorkbook();
|
||||||
}
|
}
|
||||||
/** Loading a different document outright (JSON import "replace", "reset to
|
/** Loading a different document outright (JSON import "replace", "reset to
|
||||||
@@ -2206,6 +2430,11 @@ function createStore() {
|
|||||||
addNamedRange,
|
addNamedRange,
|
||||||
updateNamedRange,
|
updateNamedRange,
|
||||||
deleteNamedRange,
|
deleteNamedRange,
|
||||||
|
// macros
|
||||||
|
addMacro,
|
||||||
|
updateMacro,
|
||||||
|
deleteMacro,
|
||||||
|
runMacro,
|
||||||
resizeChart,
|
resizeChart,
|
||||||
bringToFront,
|
bringToFront,
|
||||||
setChartView,
|
setChartView,
|
||||||
|
|||||||
@@ -193,6 +193,32 @@ export interface NamedRange {
|
|||||||
end: CellCoord;
|
end: CellCoord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A document-wide, explicitly-triggered script -- NOT callable from any
|
||||||
|
* cell formula (deliberately a separate type from `CustomFunction`, not a
|
||||||
|
* third `kind`, so that's impossible by construction, not just
|
||||||
|
* convention). Can freely read AND WRITE cells, rows/columns and tables/
|
||||||
|
* sheets across the whole document (`engine/types.ts`'s `MacroWriteHost`
|
||||||
|
* vocabulary: `SETVALUE`/`INSERTROW`/`ADDTABLE`/`ADDSHEET`/...), unlike an
|
||||||
|
* ordinary formula -- safe only because it runs OUTSIDE the passive
|
||||||
|
* recalculation graph, triggered by a click (the "▶ Ausführen" button in
|
||||||
|
* the Makros panel, or a canvas `ButtonObject`), never automatically. See
|
||||||
|
* `document.ts`'s `runMacro`/`validateMacro` and README's "Makros"
|
||||||
|
* section. Takes no parameters (a click carries no arguments) and can
|
||||||
|
* never call another macro (no such action exists) -- both deliberate,
|
||||||
|
* documented v1 scope cuts. */
|
||||||
|
export interface Macro {
|
||||||
|
id: string;
|
||||||
|
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||||
|
* function/action name. */
|
||||||
|
name: string;
|
||||||
|
/** raw statement-sequence text (assignments, FOR/IF/RETURN, and
|
||||||
|
* write-action calls), no leading "=" -- parsed via
|
||||||
|
* `engine/parser.ts`'s `parseMacroBlock`. Every cell/range reference
|
||||||
|
* inside must be qualified with a table name (`Umsatz::A1`, never bare
|
||||||
|
* `A1`) -- a macro has no "home table", see `validateMacro`. */
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||||
* `functionSuggestions()`). */
|
* `functionSuggestions()`). */
|
||||||
export interface FnSuggestion {
|
export interface FnSuggestion {
|
||||||
@@ -234,4 +260,5 @@ export interface DocumentState {
|
|||||||
csvDelimiter: ',' | ';';
|
csvDelimiter: ',' | ';';
|
||||||
customFunctions: CustomFunction[];
|
customFunctions: CustomFunction[];
|
||||||
namedRanges: NamedRange[];
|
namedRanges: NamedRange[];
|
||||||
|
macros: Macro[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user