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:
+80
-2
@@ -20,7 +20,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|
||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
||||
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. Eingebaute Namen sind nie überschreibbar. |
|
||||
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt jeden Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein. |
|
||||
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt jeden Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; `compileBlock()` rollt zusätzlich `kind: 'block'`-Funktionskörper (FOR-Schleifen) zu einem einzigen Ausdruck ab, siehe „Schleifen" unten. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. |
|
||||
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||
|
||||
@@ -147,6 +147,84 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
||||
|
||||
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
|
||||
|
||||
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
||||
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
|
||||
unverändert bleibt):
|
||||
|
||||
```ebnf
|
||||
functionBlock = { statement } "RETURN" expression ;
|
||||
statement = assignment | forLoop ;
|
||||
assignment = IDENT "=" expression ;
|
||||
forLoop = "FOR" IDENT "IN" expression "DO" assignment ;
|
||||
```
|
||||
|
||||
`FOR`/`IN`/`DO`/`RETURN` sind – wie Funktionsnamen – gewöhnliche `IDENT`-
|
||||
Token, case-insensitiv per Wortvergleich erkannt (`Parser.isKeyword`), kein
|
||||
eigener Lexer-Token dafür nötig; `=` als Zuweisung ist ein neuer, rein
|
||||
additiver `EQUALS`-Token (vorher ein `LexError`, kann also keine vorher
|
||||
gültige Formel beeinflussen). `parseFunctionBlock(src, paramNames)`
|
||||
(`parser.ts`) parst sequenziell: jeder eingeführte Name (Zuweisungsziel oder
|
||||
Schleifenvariable) wird sofort zu `Parser.paramNames` hinzugefügt – **flacher
|
||||
Namensraum**, kein Scope-Stack, ein Name bleibt für den *Rest* des Blocks
|
||||
sichtbar (auch nach seiner eigenen Schleife). Ergebnis ist ein `BlockNode`
|
||||
(`statements: (AssignNode | ForNode)[]`, `ret: Node`) – bewusst **nicht**
|
||||
Teil des `Node`-Unions, dadurch braucht keine bestehende
|
||||
`switch (node.kind)`-Stelle (Evaluator, `collectRefs`, `substitute`) einen
|
||||
neuen Fall.
|
||||
|
||||
**Keine Laufzeit-Schleife.** `userFunctions.ts#compileBlock(block, params,
|
||||
args)` kompiliert einen Block – mit den *echten* Argumenten des Aufrufers,
|
||||
nicht mehr symbolisch – zu einem einzigen Ausdrucks-`Node`, bevor er in
|
||||
`expand()`s `func`-Fall landet:
|
||||
|
||||
1. `bound`-Umgebung startet mit den Parametern (Name → Argument-`Node`).
|
||||
2. Jede `assignment` ersetzt ihren Wert mittels der **bestehenden**
|
||||
`substitute()` gegen `bound` und aktualisiert `bound`.
|
||||
3. Jede `forLoop` löst ihre Quelle (nach Substitution) über `rangeOrCellTo-
|
||||
CellNodes()` in eine konkrete Zellliste auf (`expandRange`, wie
|
||||
`evaluator.ts#evalArg` es für ein Bereichsargument tut) – ein
|
||||
Einzelzellbezug ergibt genau eine Iteration. Für jede Zelle, in
|
||||
Zellreihenfolge: die Schleifenvariable zeigt auf diese Zelle, der
|
||||
Zuweisungskörper wird gegen die **aktuelle** `bound`-Umgebung substituiert
|
||||
(sieht also den Wert der *vorherigen* Iteration) – ein Links-Fold, ein
|
||||
verschachtelter Ausdrucks-Teilbaum pro Zelle. Schleifenvariable und
|
||||
Zuweisungsziel bleiben danach in `bound` (flacher Namensraum: sichtbar mit
|
||||
ihrem letzten Wert für den Rest des Blocks).
|
||||
4. `RETURN`s Ausdruck wird zuletzt gegen die finale `bound`-Umgebung
|
||||
substituiert – das Ergebnis ist der komplette, statische Rückgabewert von
|
||||
`compileBlock`.
|
||||
|
||||
Das Ergebnis kann noch unaufgelöste `FuncNode`s enthalten (verschachtelte
|
||||
Aufrufe im Schleifenkörper) – deshalb läuft es direkt danach noch einmal
|
||||
durch `expand()` (eine Ebene tiefer), genau wie jeder andere Funktionskörper.
|
||||
`MAX_EXPANSION_DEPTH` gilt dadurch unverändert auch für verschachtelte
|
||||
`kind: 'block'`-Aufrufe.
|
||||
|
||||
**Kein Endlos-Risiko:** `MAX_UNROLL_STEPS` (10.000, Summe aller Schleifen
|
||||
eines `compileBlock`-Aufrufs) wirft einen `Error` bei Überschreitung –
|
||||
`#ERR`, wie jeder andere `expandUserFunctions`-Fehler. Ein `forLoop`, dessen
|
||||
(substituierte) Quelle weder eine Zelle noch ein Bereich ist, wirft ebenso
|
||||
(`rangeOrCellToCellNodes`); der Store prüft das bereits strukturell beim
|
||||
**Speichern** (`findInvalidForSource`, exportiert), bevor es je zu einem
|
||||
echten Aufruf kommt.
|
||||
|
||||
**Namens-Validierung ist Store-Sache, nicht Engine-Sache** (wie beim
|
||||
Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche
|
||||
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür
|
||||
`declaredBlockNames(block)` (jeder eingeführte Name, inkl. Wiederholungen
|
||||
bei Wiederverwendung – z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
|
||||
bereich DO ergebnis = ergebnis * x`, wo „ergebnis" zweimal auftaucht, aber
|
||||
**keine** Kollision ist) und `findInvalidForSource(block)`. `document.ts`s
|
||||
`validateCustomFunction` prüft damit: jeder Name gültig/nicht zellartig/kein
|
||||
reserviertes Wort (`FOR`/`IN`/`DO`/`RETURN`), kein *unterschiedlicher*
|
||||
lokaler Name überschattet einen Parameter (Wiederverwendung *desselben*
|
||||
Namens über mehrere Anweisungen ist dagegen erlaubt), und die
|
||||
Zyklus-Prüfung nutzt `collectCalledFunctionNamesInBlock(block)` (läuft das
|
||||
bestehende `collectCalledFunctionNames()` über jeden Blatt-Ausdruck des
|
||||
Blocks – kein zweiter AST-Walker nötig).
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
@@ -179,7 +257,7 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
| `#REF` | ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, **oder** ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die "gleiches Blatt wie die Formel"-Regel nicht eindeutig wird |
|
||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch/Expansionstiefen-Limit bei einer benutzerdefinierten Funktion, oder bei `kind: 'block'` eine ungültige FOR-Quelle bzw. mehr als `MAX_UNROLL_STEPS` abgerollte Zellen |
|
||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula, collectRefs, ParseError, type Node } from '../parser';
|
||||
import {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
collectRefs,
|
||||
ParseError,
|
||||
type Node,
|
||||
type Statement,
|
||||
type BlockNode,
|
||||
} from '../parser';
|
||||
|
||||
/** Compact s-expression rendering of an AST for precedence assertions. */
|
||||
function sexpr(n: Node): string {
|
||||
@@ -25,6 +33,17 @@ function sexpr(n: Node): string {
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
|
||||
|
||||
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
|
||||
function sexprStmt(s: Statement): string {
|
||||
if (s.kind === 'assign') return `${s.name}=${sexpr(s.value)}`;
|
||||
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprStmt(s.body)})`;
|
||||
}
|
||||
function sexprBlock(b: BlockNode): string {
|
||||
return `{${b.statements.map(sexprStmt).join('; ')}} RETURN ${sexpr(b.ret)}`;
|
||||
}
|
||||
const parseBlock = (s: string, paramNames: readonly string[]) =>
|
||||
sexprBlock(parseFunctionBlock(s, paramNames));
|
||||
|
||||
describe('parser precedence & associativity', () => {
|
||||
it('* / bind tighter than + -', () => {
|
||||
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
||||
@@ -160,3 +179,63 @@ describe('parser errors', () => {
|
||||
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
|
||||
it('a single FOR loop, accumulator pattern', () => {
|
||||
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{ergebnis=1; FOR(x IN param(bereich) DO ergebnis=(* param(ergebnis) param(x)))} RETURN param(ergebnis)',
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
|
||||
const src =
|
||||
'total = 0 FOR a IN r1 DO total = total + a FOR b IN r2 DO total = total + b RETURN total';
|
||||
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
||||
'{total=0; FOR(a IN param(r1) DO total=(+ param(total) param(a))); ' +
|
||||
'FOR(b IN param(r2) DO total=(+ param(total) param(b)))} RETURN param(total)',
|
||||
);
|
||||
});
|
||||
|
||||
it('a block with no statements -- just RETURN <expression>', () => {
|
||||
expect(parseBlock('RETURN 42', [])).toBe('{} RETURN 42');
|
||||
});
|
||||
|
||||
it('RETURN may be any expression, not just a bare variable', () => {
|
||||
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5} RETURN (* param(x) 2)');
|
||||
});
|
||||
|
||||
it('a plain assignment can reference an already-declared variable', () => {
|
||||
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe(
|
||||
'{a=1; b=(+ param(a) 1)} RETURN param(b)',
|
||||
);
|
||||
});
|
||||
|
||||
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
|
||||
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x RETURN total', [])).toBe(
|
||||
'{total=0; FOR(x IN range(0,0:0,4) DO total=(+ param(total) param(x)))} RETURN param(total)',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when RETURN is missing entirely', () => {
|
||||
expect(() => parseFunctionBlock('x = 1', [])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws on FOR without a matching DO', () => {
|
||||
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws on FOR without IN', () => {
|
||||
expect(() => parseFunctionBlock('FOR x bereich DO y = x RETURN y', ['bereich'])).toThrow(
|
||||
ParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when an assignment is missing "="', () => {
|
||||
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
|
||||
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,9 @@ const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
const def = (partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>): FunctionDef =>
|
||||
partial;
|
||||
const def = (
|
||||
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
|
||||
): FunctionDef => ({ kind: 'expression', ...partial });
|
||||
|
||||
describe('expandUserFunctions (AST-level)', () => {
|
||||
it('substitutes parameters with the caller\'s argument subtrees', () => {
|
||||
@@ -243,3 +244,194 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
|
||||
});
|
||||
});
|
||||
|
||||
const productBlock = {
|
||||
params: ['bereich'],
|
||||
kind: 'block' as const,
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
|
||||
};
|
||||
|
||||
describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||
it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
|
||||
// ((1 * A1) * A2) * A3
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'number', value: 1 },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
},
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
},
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a single-cell FOR source (no range) unrolls to exactly one iteration', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'number', value: 1 },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a loop variable / local stays visible (holding its last value) after its own loop', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'LASTPLUSONE',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'acc = 0 FOR x IN bereich DO acc = acc + x RETURN x + 1', // returns the loop VAR, not acc
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=LASTPLUSONE(A1:A3)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 2 }, // A3 -- the last iterated cell
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a call inside a FOR body to another (expression-kind) function is expanded too', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
|
||||
def({
|
||||
id: 's1',
|
||||
name: 'SUMDOUBLED',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
|
||||
// (0 + (A1*2)) + (A2*2) -- DOUBLE() fully inlined per iteration, no func nodes left
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'number', value: 0 },
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('multiple independent FOR loops each accumulate correctly', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'c1',
|
||||
name: 'COMBINED',
|
||||
params: ['sumRange', 'prodRange'],
|
||||
kind: 'block',
|
||||
body:
|
||||
'total = 0 FOR a IN sumRange DO total = total + a ' +
|
||||
'prod = 1 FOR b IN prodRange DO prod = prod * b ' +
|
||||
'RETURN total + prod',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=COMBINED(A1:A2; B1:B2)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'binary', op: '+', left: { kind: 'number', value: 0 }, right: { kind: 'cell', qualifier: null, col: 0, row: 0 } },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
},
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'binary', op: '*', left: { kind: 'number', value: 1 }, right: { kind: 'cell', qualifier: null, col: 1, row: 0 } },
|
||||
right: { kind: 'cell', qualifier: null, col: 1, row: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('arity mismatch is still checked for block-kind functions', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('a FOR source that is not a cell/range after substitution throws', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
}),
|
||||
]);
|
||||
// x substituted with a NumberNode -> the FOR source becomes "5 + 1", not a cell/range
|
||||
expect(() => expandUserFunctions(parseFormula('=BAD(5)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('a FOR loop over more cells than the unroll cap throws', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
// A1:A20000 -> 20000 cells, well over the 10,000-cell cap
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1:A20000)'), index)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook integration: block-kind (FOR loop) user-defined functions', () => {
|
||||
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||
});
|
||||
|
||||
it('recalculates when a cell inside the passed range changes', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('exceeding the unroll cap degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('an invalid FOR source degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=BAD(5)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
||||
export {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
collectRefs,
|
||||
ParseError,
|
||||
type Node,
|
||||
type CellNode,
|
||||
type RangeNode,
|
||||
type BlockNode,
|
||||
} from './parser';
|
||||
export { evaluate } from './evaluator';
|
||||
export { DependencyGraph } from './graph';
|
||||
@@ -35,6 +37,9 @@ export {
|
||||
buildFunctionIndex,
|
||||
expandUserFunctions,
|
||||
collectCalledFunctionNames,
|
||||
collectCalledFunctionNamesInBlock,
|
||||
declaredBlockNames,
|
||||
findInvalidForSource,
|
||||
type FunctionDef,
|
||||
} from './userFunctions';
|
||||
export {
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TokenType =
|
||||
| 'COLON'
|
||||
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
||||
| 'BANG'
|
||||
| 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body
|
||||
| 'EOF';
|
||||
|
||||
export interface Token {
|
||||
@@ -47,6 +48,7 @@ const SINGLE: Record<string, TokenType> = {
|
||||
';': 'SEMICOLON',
|
||||
':': 'COLON',
|
||||
'!': 'BANG',
|
||||
'=': 'EQUALS',
|
||||
};
|
||||
|
||||
function isDigit(ch: string): boolean {
|
||||
|
||||
+128
-2
@@ -120,13 +120,56 @@ export interface ParamNode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Block" function bodies (`kind: 'block'` custom functions) -- a small,
|
||||
// separate statement grammar for a FOR-loop-capable function body. NOT part
|
||||
// of the `Node` union above: a block is parsed by parseFunctionBlock() (a
|
||||
// distinct entry point from parseFormula()) and compiled down to a plain
|
||||
// `Node` by userFunctions.ts#compileBlock() *before* it ever reaches
|
||||
// expandUserFunctions()'s normal splicing/evaluation path -- so no existing
|
||||
// `switch (node.kind)` (evaluator, collectRefs, userFunctions#substitute)
|
||||
// needs a case for these. See src/engine/README.md's "Schleifen" section.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `name = value` -- a plain local-variable assignment, or (nested inside a
|
||||
* ForNode) one loop step. */
|
||||
export interface AssignNode {
|
||||
kind: 'assign';
|
||||
name: string;
|
||||
value: Node;
|
||||
}
|
||||
/** `FOR varName IN source DO body` -- source must resolve, once parameters
|
||||
* are substituted with the caller's real arguments, to a single cell or a
|
||||
* range (checked by userFunctions.ts); compileBlock() unrolls it into one
|
||||
* nested expression per cell, in order. */
|
||||
export interface ForNode {
|
||||
kind: 'for';
|
||||
varName: string;
|
||||
source: Node;
|
||||
body: AssignNode;
|
||||
}
|
||||
export type Statement = AssignNode | ForNode;
|
||||
/** A whole "block" function body: a sequence of statements, then a `RETURN`
|
||||
* expression. Every name introduced by a statement (assignment target or
|
||||
* loop variable) stays visible for the rest of the block -- flat scope, no
|
||||
* nested block scoping. */
|
||||
export interface BlockNode {
|
||||
kind: 'block';
|
||||
statements: Statement[];
|
||||
ret: Node;
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
/** Case-insensitively matched (compared upper-cased), like function names
|
||||
* elsewhere in the engine (functions.ts's getFunction()). */
|
||||
private readonly paramNames: ReadonlySet<string>;
|
||||
* elsewhere in the engine (functions.ts's getFunction()). Mutable only for
|
||||
* parseBlock() below: as each statement introduces a new local/loop-var
|
||||
* name, it's added here so *later* statements/the RETURN can reference it
|
||||
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
||||
* this, so its behaviour is completely unaffected. */
|
||||
private readonly paramNames: Set<string>;
|
||||
constructor(private readonly toks: Token[], paramNames: readonly string[] = []) {
|
||||
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
||||
}
|
||||
@@ -155,6 +198,70 @@ class Parser {
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Entry point for a "block" function body: `{ statement } "RETURN" expression`.
|
||||
* See parseFunctionBlock() below. */
|
||||
parseBlock(): BlockNode {
|
||||
const statements: Statement[] = [];
|
||||
while (!this.isKeyword('RETURN')) {
|
||||
statements.push(this.parseStatement());
|
||||
}
|
||||
this.next(); // consume RETURN
|
||||
const ret = this.expression();
|
||||
if (!this.at('EOF')) {
|
||||
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return { kind: 'block', statements, ret };
|
||||
}
|
||||
|
||||
private parseStatement(): Statement {
|
||||
if (this.isKeyword('FOR')) return this.parseForLoop();
|
||||
if (this.at('EOF')) {
|
||||
throw new ParseError(`Expected a statement or RETURN but reached the end at ${this.peek().pos}`);
|
||||
}
|
||||
return this.parseAssignment();
|
||||
}
|
||||
|
||||
private parseAssignment(): AssignNode {
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
const nameTok = this.next();
|
||||
if (!this.at('EQUALS')) {
|
||||
throw new ParseError(`Expected '=' after '${nameTok.value}' at ${this.peek().pos}`);
|
||||
}
|
||||
this.next();
|
||||
const value = this.expression();
|
||||
this.paramNames.add(nameTok.value.toUpperCase()); // visible from here on (flat scope)
|
||||
return { kind: 'assign', name: nameTok.value, value };
|
||||
}
|
||||
|
||||
private parseForLoop(): ForNode {
|
||||
this.next(); // consume FOR
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
|
||||
}
|
||||
const varTok = this.next();
|
||||
if (!this.isKeyword('IN')) {
|
||||
throw new ParseError(`Expected IN after 'FOR ${varTok.value}' at ${this.peek().pos}`);
|
||||
}
|
||||
this.next(); // consume IN
|
||||
const source = this.expression();
|
||||
if (!this.isKeyword('DO')) {
|
||||
throw new ParseError(`Expected DO after the FOR source at ${this.peek().pos}`);
|
||||
}
|
||||
this.next(); // consume DO
|
||||
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the DO assignment (and after)
|
||||
const body = this.parseAssignment();
|
||||
return { kind: 'for', varName: varTok.value, source, body };
|
||||
}
|
||||
|
||||
/** True iff the current token is an IDENT spelling exactly `word`
|
||||
* (case-insensitive) -- how FOR/IN/DO/RETURN are recognised, the same way
|
||||
* function names are matched elsewhere (no reserved-word lexer tokens). */
|
||||
private isKeyword(word: string): boolean {
|
||||
return this.at('IDENT') && this.peek().value.toUpperCase() === word;
|
||||
}
|
||||
|
||||
private expression(): Node {
|
||||
let left = this.term();
|
||||
while (this.at('PLUS') || this.at('MINUS')) {
|
||||
@@ -342,6 +449,25 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
|
||||
return new Parser(toks, paramNames).parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a "block" function body -- the `FOR`/`DO`/`RETURN` statement
|
||||
* grammar used by a `kind: 'block'` custom function (see
|
||||
* src/engine/userFunctions.ts and CustomFunctionsModal.vue). The leading
|
||||
* "=" is optional, same as parseFormula(). Throws {@link ParseError} /
|
||||
* {@link LexError} on malformed input, same conventions as parseFormula().
|
||||
*/
|
||||
export function parseFunctionBlock(src: string, paramNames: readonly 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, paramNames).parseBlock();
|
||||
}
|
||||
|
||||
/** Walk an AST and collect every cell/range reference node it contains. */
|
||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
||||
switch (node.kind) {
|
||||
|
||||
+183
-8
@@ -15,9 +15,26 @@
|
||||
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
|
||||
* and correctly pick up cell references that live *only* inside the
|
||||
* function body, not just the ones passed as arguments.
|
||||
*
|
||||
* A `kind: 'block'` function (a FOR/DO/RETURN body, see parser.ts's
|
||||
* `parseFunctionBlock`) works the same way in the end: `compileBlock()`
|
||||
* below *unrolls* every FOR loop -- whose range size is already known once
|
||||
* the caller's real argument is substituted in -- into a single nested
|
||||
* expression tree, one iteration deep per cell. There is no runtime loop in
|
||||
* the evaluator; by the time `expand()` is done, a block-kind call has
|
||||
* become exactly the same shape of plain expression tree as any other
|
||||
* inlined function call. See src/engine/README.md's "Schleifen" section.
|
||||
*/
|
||||
|
||||
import { parseFormula, type Node } from './parser';
|
||||
import {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
type BlockNode,
|
||||
type CellNode,
|
||||
type ForNode,
|
||||
type Node,
|
||||
} from './parser';
|
||||
import { expandRange } from './refs';
|
||||
|
||||
export interface FunctionDef {
|
||||
id: string;
|
||||
@@ -25,10 +42,24 @@ export interface FunctionDef {
|
||||
name: string;
|
||||
/** parameter names, in call order. */
|
||||
params: string[];
|
||||
/** raw formula text, no leading "=". */
|
||||
/** raw formula text, no leading "=" -- a single expression for
|
||||
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
|
||||
* `kind: 'block'`. */
|
||||
body: string;
|
||||
/** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop
|
||||
* capable). Every function predating this field is 'expression'. */
|
||||
kind: 'expression' | 'block';
|
||||
}
|
||||
|
||||
/** Total cells one `compileBlock()` call (i.e. one block-kind function call
|
||||
* site) may unroll, summed across every FOR loop in its body -- a safety
|
||||
* net against a pathologically large range (e.g. `FOR x IN A1:Z10000 DO
|
||||
* ...`), matching the "no crash, no hang" guarantee. A chain of nested
|
||||
* block-kind calls is still separately bounded by MAX_EXPANSION_DEPTH, so
|
||||
* total work across a whole formula stays finite either way. Generous for
|
||||
* anything this app's tables realistically hold. */
|
||||
const MAX_UNROLL_STEPS = 10_000;
|
||||
|
||||
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
|
||||
* rejected at definition time (see the store's cycle check), this is only
|
||||
* a last-resort safety net so a bug there degrades to a cell error rather
|
||||
@@ -53,13 +84,14 @@ export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, Fu
|
||||
*/
|
||||
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
|
||||
if (index.size === 0) return ast; // fast path: no user functions defined at all
|
||||
return expand(ast, index, new Map(), 0);
|
||||
return expand(ast, index, new Map(), new Map(), 0);
|
||||
}
|
||||
|
||||
function expand(
|
||||
node: Node,
|
||||
index: ReadonlyMap<string, FunctionDef>,
|
||||
bodyCache: Map<string, Node>,
|
||||
blockCache: Map<string, BlockNode>,
|
||||
depth: number,
|
||||
): Node {
|
||||
switch (node.kind) {
|
||||
@@ -70,18 +102,18 @@ function expand(
|
||||
case 'param':
|
||||
return node; // leaves -- nothing to expand
|
||||
case 'unary':
|
||||
return { ...node, operand: expand(node.operand, index, bodyCache, depth) };
|
||||
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
|
||||
case 'binary':
|
||||
return {
|
||||
...node,
|
||||
left: expand(node.left, index, bodyCache, depth),
|
||||
right: expand(node.right, index, bodyCache, depth),
|
||||
left: expand(node.left, index, bodyCache, blockCache, depth),
|
||||
right: expand(node.right, index, bodyCache, blockCache, depth),
|
||||
};
|
||||
case 'func': {
|
||||
// expand the arguments regardless of whether this call is itself a
|
||||
// user function -- a built-in like SUM(...) may still take a
|
||||
// user-function call as one of its arguments.
|
||||
const args = node.args.map((a) => expand(a, index, bodyCache, depth));
|
||||
const args = node.args.map((a) => expand(a, index, bodyCache, blockCache, depth));
|
||||
const def = index.get(node.name.toUpperCase());
|
||||
if (!def) return { ...node, args };
|
||||
|
||||
@@ -94,6 +126,23 @@ function expand(
|
||||
);
|
||||
}
|
||||
|
||||
if (def.kind === 'block') {
|
||||
let block = blockCache.get(def.id);
|
||||
if (!block) {
|
||||
block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it
|
||||
blockCache.set(def.id, block);
|
||||
}
|
||||
// compileBlock() already substitutes this function's own parameters
|
||||
// (it needs their *real* values to know how many cells a FOR loop
|
||||
// covers), so -- unlike the expression-kind path below -- there is
|
||||
// no separate substitute() step here. Its result may still contain
|
||||
// nested calls (to built-ins or other user functions), so it goes
|
||||
// through expand() once more, one level deeper, exactly like any
|
||||
// other function body.
|
||||
const compiled = compileBlock(block, def.params, args);
|
||||
return expand(compiled, index, bodyCache, blockCache, depth + 1);
|
||||
}
|
||||
|
||||
let body = bodyCache.get(def.id);
|
||||
if (!body) {
|
||||
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
|
||||
@@ -104,7 +153,7 @@ function expand(
|
||||
// resolving inside-out like this keeps each function's own parameters
|
||||
// scoped correctly no matter what the caller's happen to be named),
|
||||
// then substitute this function's own parameters last.
|
||||
const expandedBody = expand(body, index, bodyCache, depth + 1);
|
||||
const expandedBody = expand(body, index, bodyCache, blockCache, depth + 1);
|
||||
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
|
||||
return substitute(expandedBody, bound);
|
||||
}
|
||||
@@ -133,6 +182,77 @@ function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `kind: 'block'` function body down to a single plain expression
|
||||
* `Node`, given the caller's *real* argument nodes for this call. Statements
|
||||
* run in order against a growing `bound` environment (name -> already-
|
||||
* substituted expression tree): a plain assignment substitutes its value
|
||||
* against the current environment and stores it; a FOR loop resolves its
|
||||
* source to a concrete list of cells (throwing if it isn't one -- can only
|
||||
* happen if a param turned out, at this call site, not to be a cell/range;
|
||||
* the store's save-time check catches the far more common case of a
|
||||
* body that could *never* work), then unrolls one nested substitution per
|
||||
* cell, each seeing the *previous* iteration's value for whichever name the
|
||||
* loop body assigns -- exactly a left fold. The loop variable and the
|
||||
* loop body's assignment target both stay in `bound` afterwards (flat
|
||||
* scope: visible, holding their last iteration's value, for the rest of the
|
||||
* block) -- parseFunctionBlock() already parsed the source text with that
|
||||
* same flat-scope assumption, so this matches.
|
||||
*
|
||||
* Throws a plain `Error` on a bad FOR source or exceeding
|
||||
* {@link MAX_UNROLL_STEPS}; the caller (`expand()`'s `func` case, in turn
|
||||
* `Workbook.parseCell`) turns it into `#ERR`, same as any other
|
||||
* expandUserFunctions() failure.
|
||||
*/
|
||||
function compileBlock(block: BlockNode, paramNames: readonly string[], args: readonly Node[]): Node {
|
||||
const bound = new Map<string, Node>(paramNames.map((p, i) => [p.toUpperCase(), args[i]]));
|
||||
let steps = 0;
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'assign') {
|
||||
bound.set(stmt.name.toUpperCase(), substitute(stmt.value, bound));
|
||||
continue;
|
||||
}
|
||||
const source = substitute(stmt.source, bound);
|
||||
const cells = rangeOrCellToCellNodes(source, stmt.varName);
|
||||
steps += cells.length;
|
||||
if (steps > MAX_UNROLL_STEPS) {
|
||||
throw new Error(
|
||||
`FOR-Schleife(n) über zu viele Zellen (> ${MAX_UNROLL_STEPS} insgesamt) -- bitte einen kleineren Bereich verwenden`,
|
||||
);
|
||||
}
|
||||
for (const cell of cells) {
|
||||
const iterBound = new Map(bound);
|
||||
iterBound.set(stmt.varName.toUpperCase(), cell);
|
||||
const value = substitute(stmt.body.value, iterBound);
|
||||
bound.set(stmt.body.name.toUpperCase(), value);
|
||||
bound.set(stmt.varName.toUpperCase(), cell); // stays visible after the loop (flat scope)
|
||||
}
|
||||
}
|
||||
return substitute(block.ret, bound);
|
||||
}
|
||||
|
||||
/** A FOR loop's source, once parameters are substituted in, must be a
|
||||
* single cell or a range -- never a computed value (mirrors "a range may
|
||||
* only appear as a function argument" for ordinary formulas). Expands a
|
||||
* range to its individual cells via `expandRange` (same helper
|
||||
* `evaluator.ts#evalArg` uses for a range function argument), carrying the
|
||||
* same table/sheet qualifier through to each. */
|
||||
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
||||
if (node.kind === 'cell') return [node];
|
||||
if (node.kind === 'range') {
|
||||
return expandRange(node.start, node.end).map(
|
||||
(c): CellNode => ({
|
||||
kind: 'cell',
|
||||
qualifier: node.qualifier,
|
||||
...(node.sheetQualifier === undefined ? {} : { sheetQualifier: node.sheetQualifier }),
|
||||
col: c.col,
|
||||
row: c.row,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`FOR ${loopVarName} IN ...: die Quelle muss ein Zell- oder Bereichsbezug sein`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct function name called anywhere in `ast` (built-in or not --
|
||||
* callers filter against their own registry). Used by the store to build a
|
||||
@@ -157,3 +277,58 @@ export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every name a block declares -- an assignment target or a loop variable --
|
||||
* in the order first declared. Used by the store to validate these local
|
||||
* names the same way it validates parameters (not cell-shaped, no
|
||||
* collisions with each other or the parameters, not a reserved word); the
|
||||
* engine itself has no opinion on naming policy or German error text, so
|
||||
* it only exposes the raw names here. */
|
||||
export function declaredBlockNames(block: BlockNode): string[] {
|
||||
const out: string[] = [];
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'assign') {
|
||||
out.push(stmt.name);
|
||||
} else {
|
||||
out.push(stmt.varName, stmt.body.name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The first FOR loop, if any, whose source isn't (syntactically) a
|
||||
* parameter, a single cell, or a range -- i.e. can never resolve to a
|
||||
* concrete list of cells no matter what the caller passes. Checked at
|
||||
* save time so this is caught immediately, not just on the first real
|
||||
* call; `compileBlock` re-checks the same thing on the *substituted*
|
||||
* source at call time (needed regardless, since a parameter's shape isn't
|
||||
* known until then) as the actual enforcement. */
|
||||
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'for' && stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
|
||||
return stmt;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
|
||||
* over every leaf expression a `BlockNode` contains (each statement's
|
||||
* value/source, a FOR loop's body assignment, the RETURN expression) and
|
||||
* merges the results -- `collectCalledFunctionNames` itself needs no
|
||||
* changes, since a block is never part of the `Node` union it switches on. */
|
||||
export function collectCalledFunctionNamesInBlock(
|
||||
block: BlockNode,
|
||||
out: Set<string> = new Set(),
|
||||
): Set<string> {
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'assign') {
|
||||
collectCalledFunctionNames(stmt.value, out);
|
||||
} else {
|
||||
collectCalledFunctionNames(stmt.source, out);
|
||||
collectCalledFunctionNames(stmt.body.value, out);
|
||||
}
|
||||
}
|
||||
collectCalledFunctionNames(block.ret, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user