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:
+173
-144
@@ -6,35 +6,35 @@
|
||||
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
|
||||
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) --
|
||||
* which has no room for a "body formula with its own cell references and a
|
||||
* parameter environment". Rather than rework the evaluator, a call to a
|
||||
* user-defined function is *inlined* into the caller's AST at parse time,
|
||||
* before the graph/evaluator ever see it: the `FuncNode` is replaced by a
|
||||
* (recursively expanded) copy of the function body, with every `ParamNode`
|
||||
* swapped for the caller's own argument subtree. After that, the stored AST
|
||||
* looks exactly as if the user had written the expanded formula by hand --
|
||||
* `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.
|
||||
* parameter environment". Rather than rework the evaluator, a `kind:
|
||||
* 'expression'` call (a single-formula body) is *inlined* into the caller's
|
||||
* AST at parse time, before the graph/evaluator ever see it: the `FuncNode`
|
||||
* is replaced by a (recursively expanded) copy of the function body, with
|
||||
* every `ParamNode` swapped for the caller's own argument subtree. After
|
||||
* that, the stored AST looks exactly as if the user had written the
|
||||
* expanded formula by hand -- `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.
|
||||
* A `kind: 'block'` call (a FOR/IF/RETURN "code" body, see parser.ts's
|
||||
* `parseFunctionBlock`) is different: this module only resolves *nested*
|
||||
* function calls inside it (`expandBlock`, below) -- still at parse time,
|
||||
* still the same "inside-out" inlining -- and wraps the result in a
|
||||
* `BlockCallNode`. The block's *own* control flow (FOR iteration, IF
|
||||
* branching, an early RETURN) is only resolved at evaluation time, by a
|
||||
* small interpreter in `evaluator.ts`, since it genuinely depends on real
|
||||
* cell values. See src/engine/README.md's "Schleifen" section for the full
|
||||
* design and why that split is necessary.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
type BlockNode,
|
||||
type CellNode,
|
||||
type ForNode,
|
||||
type Node,
|
||||
} from './parser';
|
||||
import { expandRange } from './refs';
|
||||
|
||||
export interface FunctionDef {
|
||||
id: string;
|
||||
@@ -43,23 +43,14 @@ export interface FunctionDef {
|
||||
/** parameter names, in call order. */
|
||||
params: string[];
|
||||
/** raw formula text, no leading "=" -- a single expression for
|
||||
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
|
||||
* `kind: 'block'`. */
|
||||
* `kind: 'expression'`, a statement sequence (assignments, FOR/DO/END,
|
||||
* IF/THEN/ELSE/END, RETURN anywhere) for `kind: 'block'`. */
|
||||
body: string;
|
||||
/** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop
|
||||
* capable). Every function predating this field is 'expression'. */
|
||||
/** 'expression' (the original, Phase-1 form) or 'block' (FOR/IF/RETURN
|
||||
* "code"). 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
|
||||
@@ -71,10 +62,13 @@ export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, Fu
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every call to a user-defined function in `ast` with a (recursively
|
||||
* expanded, parameter-substituted) copy of its body. Calls to built-in or
|
||||
* genuinely unknown names are left untouched -- `evaluate()`'s existing
|
||||
* `#NAME?` path already covers "unknown function", built-in or not.
|
||||
* Replace every call to a user-defined function in `ast` with either a
|
||||
* (recursively expanded, parameter-substituted) copy of its body
|
||||
* (`kind: 'expression'`), or a `BlockCallNode` wrapping its (nested-calls-
|
||||
* resolved) block for evaluator.ts's interpreter to run (`kind: 'block'`).
|
||||
* Calls to built-in or genuinely unknown names are left untouched --
|
||||
* `evaluate()`'s existing `#NAME?` path already covers "unknown function",
|
||||
* built-in or not.
|
||||
*
|
||||
* Throws a plain `Error` (not `ParseError` -- there's nothing to parse here,
|
||||
* just structural problems: wrong argument count, or a definition cycle
|
||||
@@ -109,6 +103,11 @@ function expand(
|
||||
left: expand(node.left, index, bodyCache, blockCache, depth),
|
||||
right: expand(node.right, index, bodyCache, blockCache, depth),
|
||||
};
|
||||
case 'blockcall':
|
||||
// never present in freshly-parsed input -- only expand() itself ever
|
||||
// produces one (see the 'func' case below) -- but the type checker
|
||||
// doesn't know that, so: a defensive leaf, nothing to do.
|
||||
return node;
|
||||
case 'func': {
|
||||
// expand the arguments regardless of whether this call is itself a
|
||||
// user function -- a built-in like SUM(...) may still take a
|
||||
@@ -132,15 +131,16 @@ function expand(
|
||||
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);
|
||||
// resolve any *other* function calls the block contains (built-ins
|
||||
// untouched, other user functions inlined/turned into their own
|
||||
// BlockCallNode) -- same "inside-out" principle as the
|
||||
// expression-kind path below. This function's *own* parameters
|
||||
// stay as ParamNode inside `block`; evaluator.ts's interpreter
|
||||
// binds them from `args` at evaluation time, since a FOR loop's
|
||||
// iteration count and an IF's branch both depend on real cell
|
||||
// values that aren't known yet here.
|
||||
const expandedBlock = expandBlock(block, index, bodyCache, blockCache, depth + 1);
|
||||
return { kind: 'blockcall', block: expandedBlock, args, paramNames: def.params, label: def.name };
|
||||
}
|
||||
|
||||
let body = bodyCache.get(def.id);
|
||||
@@ -160,7 +160,14 @@ function expand(
|
||||
}
|
||||
}
|
||||
|
||||
function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
/**
|
||||
* Replace every `ParamNode` in `node` matching a name in `bound` with its
|
||||
* bound subtree. Exported for evaluator.ts's block interpreter, which reuses
|
||||
* this exact mechanism to resolve names (parameters *and* local variables --
|
||||
* it doesn't distinguish the two, both are just entries in its environment
|
||||
* map) against the current execution state before evaluating an expression.
|
||||
*/
|
||||
export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
switch (node.kind) {
|
||||
case 'param':
|
||||
// always found in practice (arity is checked before this runs, and
|
||||
@@ -179,78 +186,52 @@ function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
|
||||
case 'func':
|
||||
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
|
||||
case 'blockcall':
|
||||
// only `.args` can still reference an *outer* scope's parameter (e.g.
|
||||
// this call sits inside another function's own body, referencing
|
||||
// *that* function's parameter) -- `.block` itself only refers to this
|
||||
// call's *own* parameters, a separate scope bound later, at
|
||||
// evaluation time, so it's left untouched here.
|
||||
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
/** {@link expand}, run over every leaf expression a `BlockNode` contains
|
||||
* (each statement's value/condition/source, recursing into nested FOR/IF
|
||||
* bodies) instead of over a single `Node` -- resolves nested function
|
||||
* calls throughout a block-kind function's body before it's wrapped in a
|
||||
* `BlockCallNode`. */
|
||||
function expandBlock(
|
||||
block: BlockNode,
|
||||
index: ReadonlyMap<string, FunctionDef>,
|
||||
bodyCache: Map<string, Node>,
|
||||
blockCache: Map<string, BlockNode>,
|
||||
depth: number,
|
||||
): BlockNode {
|
||||
return {
|
||||
kind: 'block',
|
||||
statements: block.statements.map((stmt) => {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
|
||||
case 'return':
|
||||
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
|
||||
case 'for':
|
||||
return {
|
||||
...stmt,
|
||||
source: expand(stmt.source, index, bodyCache, blockCache, depth),
|
||||
body: expandBlock(stmt.body, index, bodyCache, blockCache, depth),
|
||||
};
|
||||
case 'ifstmt':
|
||||
return {
|
||||
...stmt,
|
||||
cond: expand(stmt.cond, index, bodyCache, blockCache, depth),
|
||||
then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
|
||||
else: stmt.else ? expandBlock(stmt.else, index, bodyCache, blockCache, depth) : null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,40 +254,60 @@ export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set
|
||||
collectCalledFunctionNames(ast.operand, out);
|
||||
break;
|
||||
default:
|
||||
break; // number/string/cell/range/param -- no nested calls
|
||||
break; // number/string/cell/range/param/blockcall -- no nested calls
|
||||
}
|
||||
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[] = [];
|
||||
* in the order first declared, recursing into nested FOR/IF bodies. Used
|
||||
* by the store to validate these local names the same way it validates
|
||||
* parameters (not cell-shaped, no collision with a parameter, 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. The *same*
|
||||
* name legitimately reappears (an accumulator re-assigned inside a loop,
|
||||
* or across two separate loops) -- that's not the store's concern either,
|
||||
* it's intentional under the flat-scope design (see parser.ts). */
|
||||
export function declaredBlockNames(block: BlockNode, out: string[] = []): string[] {
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'assign') {
|
||||
out.push(stmt.name);
|
||||
} else {
|
||||
out.push(stmt.varName, stmt.body.name);
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
out.push(stmt.name);
|
||||
break;
|
||||
case 'for':
|
||||
out.push(stmt.varName);
|
||||
declaredBlockNames(stmt.body, out);
|
||||
break;
|
||||
case 'ifstmt':
|
||||
declaredBlockNames(stmt.then, out);
|
||||
if (stmt.else) declaredBlockNames(stmt.else, out);
|
||||
break;
|
||||
case 'return':
|
||||
break;
|
||||
}
|
||||
}
|
||||
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
|
||||
/** The first FOR loop, anywhere in the block (including nested inside
|
||||
* another FOR/IF), 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; the evaluator's
|
||||
* interpreter re-checks the same thing on the *substituted* source at call
|
||||
* time regardless (needed either way, 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;
|
||||
if (stmt.kind === 'for') {
|
||||
if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
|
||||
return stmt;
|
||||
}
|
||||
const nested = findInvalidForSource(stmt.body);
|
||||
if (nested) return nested;
|
||||
} else if (stmt.kind === 'ifstmt') {
|
||||
const nested = findInvalidForSource(stmt.then) ?? (stmt.else && findInvalidForSource(stmt.else));
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -314,21 +315,49 @@ export function findInvalidForSource(block: BlockNode): ForNode | 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. */
|
||||
* value/condition/source, recursing into nested FOR/IF bodies) 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);
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
case 'return':
|
||||
collectCalledFunctionNames(stmt.value, out);
|
||||
break;
|
||||
case 'for':
|
||||
collectCalledFunctionNames(stmt.source, out);
|
||||
collectCalledFunctionNamesInBlock(stmt.body, out);
|
||||
break;
|
||||
case 'ifstmt':
|
||||
collectCalledFunctionNames(stmt.cond, out);
|
||||
collectCalledFunctionNamesInBlock(stmt.then, out);
|
||||
if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
|
||||
break;
|
||||
}
|
||||
}
|
||||
collectCalledFunctionNames(block.ret, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative static check: does *every* execution path through `block`
|
||||
* reach a `return`? True if the block contains a `return` statement
|
||||
* (statements after it are unreachable but harmless), or an `IF` with an
|
||||
* `ELSE` where *both* branches always return. A `FOR` loop is never enough
|
||||
* on its own -- even if its body always returns when it runs, the loop
|
||||
* might cover zero cells at call time, which this static check can't see
|
||||
* (the interpreter's own "ended without RETURN" -> `#ERR` is the actual,
|
||||
* always-correct enforcement; this is only an early, save-time warning).
|
||||
*/
|
||||
export function blockAlwaysReturns(block: BlockNode): boolean {
|
||||
return block.statements.some((stmt) => {
|
||||
if (stmt.kind === 'return') return true;
|
||||
if (stmt.kind === 'ifstmt' && stmt.else) {
|
||||
return blockAlwaysReturns(stmt.then) && blockAlwaysReturns(stmt.else);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user