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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 09:35:02 +02:00
co-authored by Claude Sonnet 5
parent c7b316de1b
commit e670f13077
13 changed files with 1188 additions and 36 deletions
+209
View File
@@ -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';
+229
View File
@@ -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,
+27
View File
@@ -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[];
}