diff --git a/src/data/seed.ts b/src/data/seed.ts index 0c22c57..7b5b2ba 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -141,5 +141,6 @@ export function createSeedDocument(): DocumentState { // 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 } }, ], + macros: [], }; } diff --git a/src/engine/__tests__/macros.spec.ts b/src/engine/__tests__/macros.spec.ts new file mode 100644 index 0000000..b53726b --- /dev/null +++ b/src/engine/__tests__/macros.spec.ts @@ -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>, + home = 'Umsatz', + namedRanges: Record = {}, +): 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, + ); + }); +}); diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index 6a7e8ea..84b9f49 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest'; import { parseFormula, parseFunctionBlock, + parseMacroBlock, collectRefs, + collectRefsInBlock, ParseError, type Node, type Statement, @@ -54,6 +56,8 @@ function sexprStmt(s: Statement): string { return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`; case 'ifstmt': return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`; + case 'action': + return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`; } } function sexprBlock(b: BlockNode): string { @@ -384,3 +388,65 @@ describe('parseFunctionBlock -- statement grammar for "block" function bodies', expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError); }); }); + +describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => { + const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s)); + + it('a bare NAME(args) at statement position parses as an action call', () => { + expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe( + '{ACTION(SETVALUE;Umsatz!cell(0,0);42)}', + ); + }); + + it('an action with no arguments', () => { + expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}'); + }); + + it('";"-separated arguments, including an omitted middle slot', () => { + expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}'); + }); + + it('an action call composes with local variables and & concatenation', () => { + expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe( + '{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}', + ); + }); + + it('an action call can appear inside FOR/IF bodies', () => { + expect( + parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'), + ).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}'); + }); + + it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => { + expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}'); + }); + + it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => { + // Same source: a function body has no `allowWrites`, so "SETVALUE(...)" + // at statement start falls through to parseAssignment(), which then + // fails immediately for lack of "=". + expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError); + }); + + it('rejects an action call missing its closing ")"', () => { + expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError); + }); +}); + +describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => { + it('collects a SETVALUE action\'s reference arguments too', () => { + const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)'); + const refs = collectRefsInBlock(block); + expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']); + expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']); + }); + + it('collects refs from every action argument, not just the first', () => { + const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)'); + const refs = collectRefsInBlock(block); + // INSERTROW's args are plain strings/numbers -- no refs from that + // statement; SETVALUE contributes both its target and its value's ref. + expect(refs).toHaveLength(2); + }); +}); diff --git a/src/engine/__tests__/userFunctions.spec.ts b/src/engine/__tests__/userFunctions.spec.ts index 8ca03e1..092985a 100644 --- a/src/engine/__tests__/userFunctions.spec.ts +++ b/src/engine/__tests__/userFunctions.spec.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { parseFormula } from '../parser'; +import { parseFormula, parseMacroBlock } from '../parser'; import { buildFunctionIndex, collectCalledFunctionNames, + collectCalledFunctionNamesInBlock, + expandMacroBlock, expandUserFunctions, type FunctionDef, } from '../userFunctions'; @@ -136,6 +138,30 @@ describe('collectCalledFunctionNames', () => { }); }); +describe('expandMacroBlock / collectCalledFunctionNamesInBlock: the new "action" statement arm', () => { + it('expandMacroBlock resolves a custom-function call nested inside an action argument', () => { + const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]); + const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'); + const expanded = expandMacroBlock(block, index); + const stmt = expanded.statements[0]; + expect(stmt.kind).toBe('action'); + if (stmt.kind !== 'action') throw new Error('unreachable'); + // MARGE(10;3) was inlined (kind:'expression') into its (10 - 3) body -- + // no more 'func' node calling MARGE survives in the expanded tree. + expect(collectCalledFunctionNames(stmt.args[1])).toEqual(new Set()); + }); + + it('expandMacroBlock is a no-op fast path when no custom functions are defined', () => { + const block = parseMacroBlock('SETVALUE(Umsatz::A1; 1)'); + expect(expandMacroBlock(block, buildFunctionIndex([]))).toBe(block); // same reference, not just equal + }); + + it('collectCalledFunctionNamesInBlock finds a function called inside an action\'s arguments', () => { + const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3)) INSERTROW("Umsatz"; COUNT(Umsatz::A1:A3))'); + expect(collectCalledFunctionNamesInBlock(block)).toEqual(new Set(['MARGE', 'COUNT'])); + }); +}); + describe('Workbook integration: user-defined functions end-to-end', () => { function fixture(functionDefs: FunctionDef[] = []): Workbook { const umsatz = sheet('s-umsatz', 'Umsatz', { diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index b3c22f2..ddff6cd 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -39,11 +39,11 @@ * ordinary function call this can't be resolved any earlier. */ -import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; +import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; import { getFunction, isTruthy } from './functions'; import { expandRange } from './refs'; import { substitute } from './userFunctions'; -import { FormulaError, isError, type CellValue, type EngineHost } from './types'; +import { FormulaError, isError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from './types'; export function evaluate(ast: Node, host: EngineHost): CellValue { return evalScalar(ast, host); @@ -345,9 +345,19 @@ class ReturnSignal { * either way. Generous for anything this app's tables realistically hold. */ const MAX_LOOP_STEPS = 10_000; +/** Same idea as {@link MAX_LOOP_STEPS}, but much smaller and shared across + * BOTH loop iterations and write-action calls within one macro run (see + * `runMacroActions`) -- a macro loop iteration can trigger a full + * `syncWorkbook()` rebuild (a structural action like `INSERTROW`), so the + * budget sized for pure value computation would let one bad macro freeze + * the tab for up to 10,000 rebuilds. Still generous for a per-click action. */ +const MAX_MACRO_STEPS = 1_000; + +type Budget = { steps: number; limit: number }; + function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue { const env = new Map(node.paramNames.map((p, i) => [p.toUpperCase(), node.args[i]])); - const budget = { steps: 0 }; + const budget: Budget = { steps: 0, limit: MAX_LOOP_STEPS }; try { execBlock(node.block, env, host, budget); } catch (e) { @@ -359,9 +369,41 @@ function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue { throw new Error(`'${node.label}' ended without RETURN`); } -function execBlock(block: BlockNode, env: Map, 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(); + 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, + host: EngineHost, + budget: Budget, + writeHost?: MacroWriteHost, +): void { for (const stmt of block.statements) { - execStatement(stmt, env, host, budget); + execStatement(stmt, env, host, budget, writeHost); } } @@ -388,7 +430,8 @@ function execStatement( stmt: Statement, env: Map, host: EngineHost, - budget: { steps: number }, + budget: Budget, + writeHost?: MacroWriteHost, ): void { switch (stmt.kind) { case 'assign': { @@ -404,8 +447,8 @@ function execStatement( case 'ifstmt': { const c = evalInEnv(stmt.cond, env, host); if (isError(c)) throw new ReturnSignal(c); - if (isTruthy(c)) execBlock(stmt.then, env, host, budget); - else if (stmt.else) execBlock(stmt.else, env, host, budget); + if (isTruthy(c)) execBlock(stmt.then, env, host, budget, writeHost); + else if (stmt.else) execBlock(stmt.else, env, host, budget, writeHost); return; } case 'for': { @@ -413,17 +456,144 @@ function execStatement( const cells = rangeOrCellToCellNodes(source, stmt.varName, host); for (const cell of cells) { budget.steps++; - if (budget.steps > MAX_LOOP_STEPS) { - throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${MAX_LOOP_STEPS} insgesamt)`); + if (budget.steps > budget.limit) { + throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${budget.limit} insgesamt)`); } env.set(stmt.varName.toUpperCase(), cell); - execBlock(stmt.body, env, host, budget); + execBlock(stmt.body, env, host, budget, writeHost); } return; } + case 'action': { + if (!writeHost) { + // Can't happen via parseMacroBlock (only entry point that produces + // ActionNode) -- a defensive guard, not a reachable user-facing error. + throw new Error(`Aktion '${stmt.name}' außerhalb eines Makros nicht erlaubt`); + } + budget.steps++; + if (budget.steps > budget.limit) { + throw new Error(`Makro führt zu viele Schritte aus (> ${budget.limit} insgesamt)`); + } + execAction(stmt, env, host, writeHost); + return; + } } } +/** Dispatches one macro write-action call by name -- see types.ts's + * `MacroWriteHost` for the full vocabulary and engine/README.md's + * "Makros" section for the syntax. Only `SETVALUE`'s first argument is + * special (resolved as a write TARGET, never evaluated as a value); every + * other argument of every action goes through the ordinary `evalInEnv` + * pipeline, so local variables/loop variables/`&`-concatenation all work + * for free without any per-action plumbing. */ +function execAction(stmt: ActionNode, env: Map, host: EngineHost, writeHost: MacroWriteHost): void { + const upper = stmt.name.toUpperCase(); + const arity = (n: number): void => { + if (stmt.args.length !== n) { + throw new Error(`${upper}: erwartet ${n} Argument${n === 1 ? '' : 'e'}, ${stmt.args.length} übergeben`); + } + }; + const str = (i: number): string => requireString(evalInEnv(stmt.args[i], env, host), upper); + const num = (i: number): number => requireNumber(evalInEnv(stmt.args[i], env, host), upper); + switch (upper) { + case 'SETVALUE': { + arity(2); + const target = resolveWriteTarget(substitute(stmt.args[0], env), host); + const v = evalInEnv(stmt.args[1], env, host); + if (isError(v)) throw new Error(`SETVALUE: ${v.code}`); + const text = toText(v); + if (isError(text)) throw new Error(`SETVALUE: ${text.code}`); + writeHost.setValue(target, text); + return; + } + case 'INSERTROW': + arity(2); + return writeHost.insertRow(str(0), num(1)); + case 'DELETEROW': + arity(2); + return writeHost.deleteRow(str(0), num(1)); + case 'INSERTCOL': + arity(2); + return writeHost.insertCol(str(0), num(1)); + case 'DELETECOL': + arity(2); + return writeHost.deleteCol(str(0), num(1)); + case 'ADDTABLE': + arity(2); + return writeHost.addTable(str(0), str(1)); + case 'RENAMETABLE': + arity(2); + return writeHost.renameTable(str(0), str(1)); + case 'DELETETABLE': + arity(1); + return writeHost.deleteTable(str(0)); + case 'ADDSHEET': + arity(1); + return writeHost.addSheet(str(0)); + case 'RENAMESHEET': + arity(2); + return writeHost.renameSheet(str(0), str(1)); + case 'DELETESHEET': + arity(1); + return writeHost.deleteSheet(str(0)); + default: + throw new Error(`Unbekannte Aktion '${stmt.name}'`); + } +} + +function requireString(v: CellValue, ctx: string): string { + if (isError(v)) throw new Error(`${ctx}: ${v.code}`); + if (typeof v !== 'string') throw new Error(`${ctx}: erwartet einen Text als Argument`); + return v; +} + +function requireNumber(v: CellValue, ctx: string): number { + const n = toNumber(v); + if (isError(n)) throw new Error(`${ctx}: erwartet eine Zahl als Argument`); + return n; +} + +/** Resolve a `SETVALUE` target (already `substitute()`d against the current + * `env`, mirroring how `execStatement`'s `'for'` case resolves its own + * source) into concrete cell coordinates to write to -- the write-side + * twin of `rangeOrCellToCellNodes`. A range target broadcasts the same + * value to every cell in it (Excel's Ctrl+Enter fill semantics). Unlike a + * normal read, an unqualified `qualifier === null` is a hard error here, + * never "this table" -- a macro has no home table (see + * store/document.ts#validateMacro, which already rejects this at save + * time; reaching it at runtime would mean a target came from a + * dynamically-substituted value the static check couldn't see). + * + * The `'namedref'` branch below is currently unreachable through any + * actually-parseable macro: `parseMacroBlock` turns off the bare- + * identifier named-range fallback, same as a custom-function body does + * and for the same reason (a typo'd local variable must fail loudly, not + * silently resolve to an unrelated document-wide named range) -- so a + * `NamedRefNode` can never appear in a macro's AST at all. Kept anyway, + * both as a defensive backstop and because it mirrors + * `rangeOrCellToCellNodes`'s identical shape below -- named-range support + * for macro targets is a documented, deliberate gap (see README's + * "Bekannte Grenzen"), not an oversight. */ +function resolveWriteTarget(node: Node, host: EngineHost): CellRef[] { + let n = node; + if (n.kind === 'namedref') { + const resolved = resolveNamedRef(n, host); + if (isError(resolved)) throw new Error(`SETVALUE: unbekannter Name '${n.name}'`); + n = resolved; + } + if (n.kind === 'cell' || n.kind === 'range') { + if (n.qualifier == null) { + throw new Error('SETVALUE: Zellbezüge in Makros müssen die Tabelle nennen, z. B. Umsatz::A1'); + } + const tableId = host.resolveTableName(n.qualifier, n.sheetQualifier ?? null); + if (!tableId) throw new Error(`SETVALUE: unbekannte Tabelle '${n.qualifier}'`); + if (n.kind === 'cell') return [{ tableId, col: n.col, row: n.row }]; + return expandRange(n.start, n.end).map((c) => ({ tableId, col: c.col, row: c.row })); + } + throw new Error('SETVALUE: das erste Argument muss ein Zell- oder Bereichsbezug sein'); +} + /** A FOR loop's source, once names are resolved against the current * environment, must be a single cell, a range, or a named range -- never a * computed value (mirrors "a range may only appear as a function argument" diff --git a/src/engine/index.ts b/src/engine/index.ts index c056edd..d7574c8 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -9,15 +9,18 @@ export { tokenize, LexError, type Token, type TokenType } from './lexer'; export { parseFormula, parseFunctionBlock, + parseMacroBlock, collectRefs, + collectRefsInBlock, ParseError, type Node, type CellNode, type RangeNode, type NamedRefNode, type BlockNode, + type ActionNode, } from './parser'; -export { evaluate } from './evaluator'; +export { evaluate, runMacroActions } from './evaluator'; export { DependencyGraph } from './graph'; export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions'; export { @@ -47,10 +50,12 @@ export { export { buildFunctionIndex, expandUserFunctions, + expandMacroBlock, collectCalledFunctionNames, collectCalledFunctionNamesInBlock, declaredBlockNames, findInvalidForSource, + findInvalidActionTarget, blockAlwaysReturns, type FunctionDef, } from './userFunctions'; @@ -65,5 +70,6 @@ export { type CellRef, type Scalar, type EngineHost, + type MacroWriteHost, type NamedRangeDef, } from './types'; diff --git a/src/engine/parser.ts b/src/engine/parser.ts index aa36319..ef91996 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -240,7 +240,20 @@ export interface IfNode { then: BlockNode; else: BlockNode | null; } -export type Statement = AssignNode | ForNode | IfNode | ReturnNode; +/** `NAME(arg; arg; ...)` at statement position -- a call into the macro + * write-action vocabulary (`SETVALUE`, `INSERTROW`, `ADDTABLE`, ...; see + * engine/types.ts's `MacroWriteHost` and evaluator.ts's `execStatement`). + * Only ever produced when the parser's `allowWrites` flag is on, which is + * only true for `parseMacroBlock()` -- so this can *never* appear in a + * `kind: 'block'` custom function's body (always parsed with + * `allowWrites: false`), meaning a custom function embedded inside a + * macro's expressions can compute a value but can never itself write. */ +export interface ActionNode { + kind: 'action'; + name: string; + args: Node[]; +} +export type Statement = AssignNode | ForNode | IfNode | ReturnNode | ActionNode; /** A sequence of statements -- a whole function body, or the body of a * FOR/IF. Every name a statement introduces (assignment target or loop * variable) stays visible for the *rest* of the execution, including after @@ -286,13 +299,21 @@ class Parser { * branch and parseFormula()'s doc comment for why function bodies opt * out of the fallback. */ private readonly namedRefFallback: boolean; + /** Whether `NAME(args)` at statement position parses as an `ActionNode` + * (a macro write-action call) instead of falling through to + * `parseAssignment()` (which then fails, since assignment requires "=" + * right after the name). Only `parseMacroBlock()` turns this on -- see + * `ActionNode`'s doc comment for why that containment matters. */ + private readonly allowWrites: boolean; constructor( private readonly toks: Token[], paramNames: readonly string[] = [], namedRefFallback = true, + allowWrites = false, ) { this.paramNames = new Set(paramNames.map((p) => p.toUpperCase())); this.namedRefFallback = namedRefFallback; + this.allowWrites = allowWrites; } private peek(): Token { @@ -345,9 +366,43 @@ class Parser { if (this.isKeyword('FOR')) return this.parseForLoop(); if (this.isKeyword('IF')) return this.parseIfStmt(); if (this.isKeyword('RETURN')) return this.parseReturnStmt(); + // "NAME(" at statement start, only inside a macro body -- unambiguous + // vs. assignment, which requires "=" (a different token type) right + // after the name. + if (this.allowWrites && this.at('IDENT') && this.toks[this.i + 1]?.type === 'LPAREN') { + return this.parseAction(); + } return this.parseAssignment(); } + private parseAction(): ActionNode { + const nameTok = this.next(); // IDENT, guaranteed by the caller's lookahead + this.next(); // consume "(" + const args = this.parseArgList(); + this.expect('RPAREN'); + return { kind: 'action', name: nameTok.value, args }; + } + + /** `[ arg { ";" arg } ]` -- shared by a function-call expression + * (`primary()`) and a statement-position action call (`parseAction()`). + * An empty slot (leading/middle/trailing `;`, right up against `;` or + * the caller's closing `)`) parses as an `OmittedNode`, same as a + * function call's own argument list -- the caller consumes the closing + * `)` itself. */ + private parseArgList(): Node[] { + const args: Node[] = []; + const argOrOmitted = (): Node => + this.at('SEMICOLON') || this.at('RPAREN') ? { kind: 'omitted' } : this.expression(); + if (!this.at('RPAREN')) { + args.push(argOrOmitted()); + while (this.at('SEMICOLON')) { + this.next(); + args.push(argOrOmitted()); + } + } + return args; + } + private parseAssignment(): AssignNode { if (!this.at('IDENT')) { throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`); @@ -528,19 +583,7 @@ class Parser { // function call? if (this.at('LPAREN')) { this.next(); - const args: Node[] = []; - // an argument slot with nothing in it (leading/middle/trailing `;`, - // right up against `;` or `)`) parses as an OmittedNode instead of - // failing -- see OmittedNode's doc comment for why. - const argOrOmitted = (): Node => - this.at('SEMICOLON') || this.at('RPAREN') ? { kind: 'omitted' } : this.expression(); - if (!this.at('RPAREN')) { - args.push(argOrOmitted()); - while (this.at('SEMICOLON')) { - this.next(); - args.push(argOrOmitted()); - } - } + const args = this.parseArgList(); this.expect('RPAREN'); return { kind: 'func', name: t.value, args }; } @@ -694,6 +737,29 @@ export function parseFunctionBlock(src: string, paramNames: readonly string[]): return new Parser(toks, paramNames, false).parseBlock(); } +/** + * Parse a macro body -- the same statement grammar as `parseFunctionBlock` + * (assignments, `FOR`/`IF`/`RETURN`), plus `NAME(args)` write-action calls + * (`ActionNode`; see its doc comment), which only this entry point enables. + * A macro takes no parameters (triggered by a click, not called with + * arguments) and gets no named-range fallback (same reasoning as a function + * body: a bare identifier used before its own declaring statement should + * fail immediately, not silently resolve to a same-named document-wide + * named range) -- so `paramNames`/`namedRefFallback` are fixed at `[]`/ + * `false`, only `allowWrites` differs from `parseFunctionBlock`. + */ +export function parseMacroBlock(src: string): BlockNode { + const body = src.startsWith('=') ? src.slice(1) : src; + let toks: Token[]; + try { + toks = tokenize(body); + } catch (e) { + if (e instanceof LexError) throw new ParseError(e.message); + throw e; + } + return new Parser(toks, [], false, true).parseBlock(); +} + /** Walk an AST and collect every cell/range/named-range reference node it * contains. For a `blockcall`, walks both its (already-resolved) `args` and * every expression reachable inside its `block` -- conservatively: a @@ -732,7 +798,14 @@ export function collectRefs( return out; } -function collectRefsInBlock(block: BlockNode, out: Array): 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 = [], +): Array { for (const stmt of block.statements) { switch (stmt.kind) { case 'assign': @@ -750,6 +823,10 @@ function collectRefsInBlock(block: BlockNode, out: Array collectRefs(a, out)); + break; } } + return out; } diff --git a/src/engine/types.ts b/src/engine/types.ts index 22ebd90..f855e97 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -140,3 +140,42 @@ export interface EngineHost { */ getComputed(tableId: string, col: number, row: number): CellValue; } + +/** + * The document's WRITE surface, as seen by a running macro (see + * evaluator.ts's `runMacroActions`/`execAction` and engine/README.md's + * "Makros" section). Deliberately separate from {@link EngineHost} -- that + * interface stays pure/read-only, implemented by `Workbook` alone, used by + * every ordinary formula evaluation; `MacroWriteHost` is only ever + * implemented by the store (`document.ts#runMacro`), only ever passed to + * the interpreter for an explicitly-triggered macro run, and never reaches + * the passive recalculation pass at all -- a macro's write is + * indistinguishable, from the engine's point of view, from the same edit + * made by hand through the UI. + * + * Every table/sheet argument below is a plain qualifier STRING (e.g. + * `"Umsatz"` or `"Blatt2!Kosten"`, split on the first `!`) rather than a + * resolved id or a dedicated reference AST node -- resolved fresh, by the + * implementation, exactly like a formula's own `Tabelle!Zelle` qualifier + * is resolved fresh on every read (never cached), so a macro that renames + * or adds a table earlier in its own run can address it by its new name + * later in the same run. + */ +export interface MacroWriteHost { + /** Write the same raw cell text (auto-detected as a formula if it starts + * with "=", exactly like typing it manually) into every given cell -- + * one cell for a `SETVALUE` on a single reference, several for a range + * target (broadcasting the same value, Excel's Ctrl+Enter semantics). */ + setValue(cells: CellRef[], text: string): void; + insertRow(tableRef: string, at: number): void; + deleteRow(tableRef: string, at: number): void; + insertCol(tableRef: string, at: number): void; + deleteCol(tableRef: string, at: number): void; + /** `sheetRef` is a plain sheet name (not table-qualified). */ + addTable(sheetRef: string, name: string): void; + renameTable(tableRef: string, newName: string): void; + deleteTable(tableRef: string): void; + addSheet(name: string): void; + renameSheet(sheetRef: string, newName: string): void; + deleteSheet(sheetRef: string): void; +} diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts index 3b41f4e..fba9b95 100644 --- a/src/engine/userFunctions.ts +++ b/src/engine/userFunctions.ts @@ -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): 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, @@ -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; diff --git a/src/io/json.ts b/src/io/json.ts index 88a4ca3..ff0eb52 100644 --- a/src/io/json.ts +++ b/src/io/json.ts @@ -15,6 +15,7 @@ import type { CustomFunction, DocumentState, HAlign, + Macro, NamedRange, Sheet, SheetObject, @@ -100,6 +101,32 @@ function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet): NamedR 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; function colorOrUndef(x: unknown): string | undefined { return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined; @@ -375,6 +402,7 @@ export function validateDocument(x: unknown): DocumentState { csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',', customFunctions: normaliseCustomFunctions(doc.customFunctions), namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds), + macros: normaliseMacros(doc.macros), }; } @@ -439,6 +467,7 @@ export function migrateToV2(x: unknown): DocumentState { csvDelimiter: ',', customFunctions: [], namedRanges: [], + macros: [], }; } diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 0fe38ca..8693d51 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -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")', () => { const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis'; diff --git a/src/store/document.ts b/src/store/document.ts index f977232..3c84a58 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -27,12 +27,18 @@ import { parseA1, parseFormula, parseFunctionBlock, + parseMacroBlock, + collectRefsInBlock, hasFunction, collectCalledFunctionNames, collectCalledFunctionNamesInBlock, declaredBlockNames, findInvalidForSource, + findInvalidActionTarget, blockAlwaysReturns, + buildFunctionIndex, + expandMacroBlock, + runMacroActions, parseDateTimeLiteral, BUILTIN_SIGNATURES, type CellRef, @@ -41,6 +47,8 @@ import { type Token, type FunctionDef, type NamedRangeDef, + type MacroWriteHost, + type EngineHost, } from '../engine'; import { formatValue, @@ -57,6 +65,7 @@ import type { CustomFunction, DocumentState, FnSuggestion, + Macro, Sheet, SelectionState, SheetObject, @@ -498,6 +507,7 @@ function createStore() { csvDelimiter: d.csvDelimiter, customFunctions: d.customFunctions, namedRanges: d.namedRanges, + macros: d.macros, }); let historySnapshot = snapshot(); let historyFingerprint = contentFingerprint(historySnapshot); @@ -626,6 +636,12 @@ function createStore() { // ---- lookups -------------------------------------------------- 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]); function objectById(id: string | null): SheetObject | undefined { @@ -930,6 +946,27 @@ function createStore() { * later switched to "Mit Schleife"). */ 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 * 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 } + // ---- 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) { const o = objectById(objId); if (!o) return; @@ -2109,6 +2332,7 @@ function createStore() { doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ','; doc.customFunctions = next.customFunctions ?? []; doc.namedRanges = next.namedRanges ?? []; + doc.macros = next.macros ?? []; syncWorkbook(); } /** Loading a different document outright (JSON import "replace", "reset to @@ -2206,6 +2430,11 @@ function createStore() { addNamedRange, updateNamedRange, deleteNamedRange, + // macros + addMacro, + updateMacro, + deleteMacro, + runMacro, resizeChart, bringToFront, setChartView, diff --git a/src/store/types.ts b/src/store/types.ts index 03dfbbf..655d1dc 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -193,6 +193,32 @@ export interface NamedRange { 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 * `functionSuggestions()`). */ export interface FnSuggestion { @@ -234,4 +260,5 @@ export interface DocumentState { csvDelimiter: ',' | ';'; customFunctions: CustomFunction[]; namedRanges: NamedRange[]; + macros: Macro[]; }