Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere

Replaces the previous FOR-only, single-statement-body, static-unrolling
design with genuine control flow: FOR var IN source DO <block> END loop
bodies with any number of statements (including nested loops/IFs),
IF cond THEN <block> [ELSE <block>] END branching (nestable in any
block), and RETURN as an ordinary statement usable anywhere -- it
aborts execution immediately with its value, including breaking out of
a loop early.

This required a real, if small, architecture change: an IF branch or an
early RETURN depends on actual cell values, which the previous "unroll
everything into one static formula at parse time" approach can't
resolve (loop *counts* are knowable up front from a range's size;
branch *outcomes* aren't). A kind: 'block' call is now inlined only as
far as resolving nested function calls (expandBlock, parse-time, same
inside-out principle as before) and wrapped in a new BlockCallNode;
its own control flow is interpreted at evaluation time by a small
tree-walking interpreter added to evaluator.ts (execBlock/execStatement,
a thrown ReturnSignal for early exit), reusing userFunctions.ts's
substitute() (now exported) to resolve parameter/local-variable names
against the current environment. Dependency tracking stays fully
correct and conservative: collectRefs() walks into a BlockCallNode's
block (new collectRefsInBlock), so a cell referenced only in a branch
that doesn't happen to run is still a tracked precedent -- same
principle as an ordinary IF() in any spreadsheet.

Also added: comparison operators (= <> > < >= <=, result 1/0) as a new,
loosest-binding precedence level in the *general* expression grammar
(not just inside a block's IF) -- needed for IF conditions to be useful
at all. A definition-time static check (blockAlwaysReturns) rejects a
function at save time when it's not obviously guaranteed to reach
RETURN on every path (an IF without an ELSE, or one where only one
branch returns); the interpreter's own runtime "ended without RETURN"
-> #ERR remains the actual enforcement for cases the static check can't
see (e.g. a loop over an empty range).

Nested loops (FOR inside FOR) and nested IFs work as an emergent
property of the recursive statement grammar -- verified with dedicated
tests, not just assumed.

257 tests passing (30 net new/rewritten for this change), vue-tsc/
eslint/build clean. Verified live in the running app: a FIRSTOVER(range;
threshold) function using a FOR loop with a nested IF and an early
RETURN, confirmed it returns the first match, confirmed changing a
cell *past* the match point doesn't affect the result while changing
the match point itself does, and confirmed a function without a
guaranteed RETURN is rejected inline at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 22:41:55 +02:00
co-authored by Claude Sonnet 5
parent 6daea9f956
commit 01ea18106c
13 changed files with 1143 additions and 484 deletions
+26 -1
View File
@@ -24,7 +24,12 @@ export type TokenType =
| 'COLON'
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
| 'BANG'
| 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body
| 'EQUALS' // "=" -- assignment (block body) or equality comparison
| 'NEQ' // "<>"
| 'LT' // "<"
| 'GT' // ">"
| 'LTE' // "<="
| 'GTE' // ">="
| 'EOF';
export interface Token {
@@ -49,6 +54,8 @@ const SINGLE: Record<string, TokenType> = {
':': 'COLON',
'!': 'BANG',
'=': 'EQUALS',
'<': 'LT',
'>': 'GT',
};
function isDigit(ch: string): boolean {
@@ -155,6 +162,24 @@ export function tokenize(src: string): Token[] {
continue;
}
// two-character comparison operators -- must be checked before the
// single-character "<"/">" fallback below
if (ch === '<' && src[i + 1] === '=') {
tokens.push({ type: 'LTE', value: '<=', pos: i });
i += 2;
continue;
}
if (ch === '>' && src[i + 1] === '=') {
tokens.push({ type: 'GTE', value: '>=', pos: i });
i += 2;
continue;
}
if (ch === '<' && src[i + 1] === '>') {
tokens.push({ type: 'NEQ', value: '<>', pos: i });
i += 2;
continue;
}
// single-character punctuation / operators
const t = SINGLE[ch];
if (t) {