Add FOR-loop "code" bodies to user-defined functions (kind: 'block')
A custom function body could previously only be a single expression -- no way to iterate over a range's elements to build something like PRODUCT (multiply every cell together), since none of the built-ins (SUM/AVG/MIN/MAX/COUNT) do that and a body can only compose existing functions, not define a new reduction. This adds a second body kind, 'block': a sequence of `name = expression` assignments and `FOR var IN source DO name = expression` loops, ending in `RETURN expression`. Flat scope -- every introduced name stays visible for the rest of the body, including after its own loop, holding its last iteration's value. No runtime loop in the evaluator: a loop's source size is already known once the caller's real argument is substituted in, so userFunctions.ts's new compileBlock() unrolls every FOR loop into a single nested expression (a left fold) at the same parse-time inlining point Phase 1 already used -- the result is exactly the same shape of static AST as any other inlined function call, so evaluator.ts and graph.ts need no changes, dependency edges on every unrolled cell are picked up automatically, and a bounded MAX_UNROLL_STEPS (10,000) keeps it crash/hang-free. parser.ts's parseFunctionBlock() parses the new grammar (a purely additive EQUALS lexer token; FOR/IN/DO/RETURN are plain case-matched IDENTs, not new reserved tokens) into a BlockNode kept outside the existing Node union, so no existing switch needs a new case. Store: CustomFunction/FunctionDef gain `kind: 'expression' | 'block'` (defaulted for every function saved before this existed, including via JSON import). validateCustomFunction validates the new grammar's local/ loop-variable names the same way as parameters (not cell-shaped, no reserved words, no shadowing a parameter -- but reusing the same local across statements, the accumulator pattern, is correctly not a collision) and that every FOR source is structurally a parameter, cell, or range. UI: a "𝑓 Funktionen" form toggle between "Einfache Formel" and "Mit Schleife", with its own placeholder/hint (a PRODUCT example) and a small badge on block-kind rows in the list. 237 tests passing (34 new), vue-tsc/eslint/build clean. Verified live in the running app: defined PRODUCT via the loop UI, called =PRODUCT(Umsatz!B2:E6), confirmed the correct value and recalculation on a change to a cell inside the range, and an inline rejection for an invalid FOR source at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+89
-18
@@ -26,8 +26,12 @@ import {
|
||||
looksLikeCell,
|
||||
parseA1,
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
hasFunction,
|
||||
collectCalledFunctionNames,
|
||||
collectCalledFunctionNamesInBlock,
|
||||
declaredBlockNames,
|
||||
findInvalidForSource,
|
||||
type CellValue,
|
||||
type TableModel,
|
||||
type Token,
|
||||
@@ -87,7 +91,13 @@ function toTableModels(doc: DocumentState): TableModel[] {
|
||||
}
|
||||
|
||||
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
||||
return doc.customFunctions.map((f) => ({ id: f.id, name: f.name, params: f.params, body: f.body }));
|
||||
return doc.customFunctions.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
params: f.params,
|
||||
body: f.body,
|
||||
kind: f.kind,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||||
@@ -661,9 +671,11 @@ function createStore() {
|
||||
* tolerant of a body that -- in theory -- no longer parses (shouldn't
|
||||
* happen; a save always validates first, but this is only used for a
|
||||
* best-effort cycle check, so fail open rather than block unrelated saves). */
|
||||
function bodyCallSet(params: string[], body: string): Set<string> {
|
||||
function bodyCallSet(f: Pick<CustomFunction, 'params' | 'body' | 'kind'>): Set<string> {
|
||||
try {
|
||||
return collectCalledFunctionNames(parseFormula(body, params));
|
||||
return f.kind === 'block'
|
||||
? collectCalledFunctionNamesInBlock(parseFunctionBlock(f.body, f.params))
|
||||
: collectCalledFunctionNames(parseFormula(f.body, f.params));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
@@ -690,18 +702,29 @@ function createStore() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** FOR/IN/DO/RETURN double as block-body keywords (see parser.ts's
|
||||
* parseFunctionBlock) -- never usable as a parameter/local name, in
|
||||
* either kind of function (so a name stays valid if the function is
|
||||
* later switched to "Mit Schleife"). */
|
||||
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
||||
|
||||
/**
|
||||
* Full validation for add/update: name & parameter shape, no collision with
|
||||
* a built-in or another custom function, the body parses, and -- simulating
|
||||
* the *whole* call graph with this function's (possibly new) definition in
|
||||
* place -- no cycle. Returns a user-facing (German) error string, or null
|
||||
* when the definition is safe to save. `ignoreId` excludes the function
|
||||
* being edited from the collision/graph checks against its own old state.
|
||||
* a built-in or another custom function, the body parses (as an
|
||||
* expression, or -- for `kind: 'block'` -- as a FOR/DO/RETURN statement
|
||||
* sequence, with every local/loop-variable name validated the same way as
|
||||
* a parameter and every FOR source structurally a parameter/cell/range),
|
||||
* and -- simulating the *whole* call graph with this function's (possibly
|
||||
* new) definition in place -- no cycle. Returns a user-facing (German)
|
||||
* error string, or null when the definition is safe to save. `ignoreId`
|
||||
* excludes the function being edited from the collision/graph checks
|
||||
* against its own old state.
|
||||
*/
|
||||
function validateCustomFunction(
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
kind: CustomFunction['kind'],
|
||||
ignoreId: string | null,
|
||||
): string | null {
|
||||
const cleanName = name.trim();
|
||||
@@ -723,32 +746,77 @@ function createStore() {
|
||||
for (const p of cleanParams) {
|
||||
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
||||
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
||||
if (RESERVED_NAMES.has(p.toUpperCase())) {
|
||||
return `"${p}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Parametername verwendet werden.`;
|
||||
}
|
||||
}
|
||||
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
||||
return 'Parameternamen müssen eindeutig sein.';
|
||||
}
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parseFormula(body, cleanParams);
|
||||
} catch (e) {
|
||||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||
let ownCalls: Set<string>;
|
||||
if (kind === 'block') {
|
||||
let block;
|
||||
try {
|
||||
block = parseFunctionBlock(body, cleanParams);
|
||||
} catch (e) {
|
||||
return `Code ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
// declaredBlockNames() lists every assignment target/loop variable, but
|
||||
// the SAME name legitimately reappears across statements (that's the
|
||||
// whole point of an accumulator: `ergebnis = 1`, then later `FOR x IN
|
||||
// bereich DO ergebnis = ergebnis * x` re-assigns "ergebnis", it doesn't
|
||||
// declare a second one) -- so shape/reserved-word checks run on every
|
||||
// occurrence (harmless to repeat), but the "no collision" check only
|
||||
// looks at each *distinct* local name against the parameters, never
|
||||
// against other locals (re-using a local's own name is exactly the
|
||||
// point; two locals can even share a name across two separate loops).
|
||||
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.`;
|
||||
}
|
||||
}
|
||||
const paramSet = new Set(cleanParams.map((p) => p.toLowerCase()));
|
||||
for (const n of new Set(localNames.map((x) => x.toLowerCase()))) {
|
||||
if (paramSet.has(n)) return `"${n}" ist bereits ein Parametername.`;
|
||||
}
|
||||
const badFor = findInvalidForSource(block);
|
||||
if (badFor) {
|
||||
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||||
}
|
||||
ownCalls = collectCalledFunctionNamesInBlock(block);
|
||||
} else {
|
||||
let ast;
|
||||
try {
|
||||
ast = parseFormula(body, cleanParams);
|
||||
} catch (e) {
|
||||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
ownCalls = collectCalledFunctionNames(ast);
|
||||
}
|
||||
|
||||
const graph = new Map<string, Set<string>>();
|
||||
for (const f of doc.customFunctions) {
|
||||
if (f.id === ignoreId) continue;
|
||||
graph.set(f.name.toUpperCase(), bodyCallSet(f.params, f.body));
|
||||
graph.set(f.name.toUpperCase(), bodyCallSet(f));
|
||||
}
|
||||
graph.set(cleanName.toUpperCase(), collectCalledFunctionNames(ast));
|
||||
graph.set(cleanName.toUpperCase(), ownCalls);
|
||||
if (callGraphHasCycle(graph)) {
|
||||
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addCustomFunction(name: string, params: string[], body: string): string | { error: string } {
|
||||
const error = validateCustomFunction(name, params, body, null);
|
||||
function addCustomFunction(
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
kind: CustomFunction['kind'] = 'expression',
|
||||
): string | { error: string } {
|
||||
const error = validateCustomFunction(name, params, body, kind, null);
|
||||
if (error) return { error };
|
||||
const id = uid('fn');
|
||||
const custom: CustomFunction = {
|
||||
@@ -756,6 +824,7 @@ function createStore() {
|
||||
name: name.trim(),
|
||||
params: params.map((p) => p.trim()),
|
||||
body: body.startsWith('=') ? body.slice(1) : body,
|
||||
kind,
|
||||
};
|
||||
doc.customFunctions.push(custom);
|
||||
syncWorkbook();
|
||||
@@ -767,14 +836,16 @@ function createStore() {
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
kind: CustomFunction['kind'] = 'expression',
|
||||
): void | { error: string } {
|
||||
const f = doc.customFunctions.find((x) => x.id === id);
|
||||
if (!f) return { error: 'Funktion nicht gefunden.' };
|
||||
const error = validateCustomFunction(name, params, body, id);
|
||||
const error = validateCustomFunction(name, params, body, kind, id);
|
||||
if (error) return { error };
|
||||
f.name = name.trim();
|
||||
f.params = params.map((p) => p.trim());
|
||||
f.body = body.startsWith('=') ? body.slice(1) : body;
|
||||
f.kind = kind;
|
||||
syncWorkbook();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user