Release the scope a skipped BEGIN block's loop pushed

`akbasic_parse_for()` and `akbasic_parse_do()` create their environment while
the line is *parsed*; whether to skip it is decided afterwards, when the line is
evaluated. So a loop inside a block that was not taken pushed a scope, its body
was skipped, and the `NEXT` or `LOOP` that would have popped it was skipped too.
Nothing else ever would.

At the top level that exhausted the pool after thirty-two skips. Inside a
routine it was far more confusing: the orphan sat between the routine and its
caller, so the `RETURN` after the block reported "RETURN outside the context of
GOSUB" from a routine that plainly *was* entered by a `GOSUB` -- naming the one
construct that was not at fault, which is why it cost an evening to find.

The skip now releases what parsing pushed.

**Narrower than it first looks.** Releasing on any skip breaks
tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas: a
zero-iteration `FOR` skips its body by the same mechanism, and there the orphan
is load-bearing -- it absorbs the inner `NEXT` so the outer `NEXT` still finds
its own `FOR`. Releasing it turns that case into "NEXT outside the context of
FOR". So the release is conditional on the skip being a *block* skip, which is
decidable because nothing inside a skipped block ever runs to arm a `NEXT` wait.
Both halves are asserted side by side in tests/structure_verbs.c, the second one
citing the golden case that caught it.

The forty-skip case names its own step budget: a skipped line is not free, and
forty passes over a five-line block cost about 2700 steps against the shared
runner's 2000.

Chapter 18's trap 3 becomes history rather than a warning, and the note in Step
5 that called `GOTO`-guarded loops "not a style choice" now says why the shape
is kept anyway.

TODO.md section 9 item 2, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:55:10 -04:00
parent f5a35b4af6
commit 5d33237eed
4 changed files with 183 additions and 24 deletions

View File

@@ -816,6 +816,45 @@ akerr_ErrorContext *akbasic_runtime_interpret(akbasic_Runtime *obj, akbasic_ASTL
if ( akbasic_environment_is_waiting_for_any(obj->environment) ) {
if ( expr->leaftype != AKBASIC_LEAF_COMMAND ||
!akbasic_environment_is_waiting_for(obj->environment, expr->identifier) ) {
/*
* **A skipped loop has already pushed its scope, and this is where it
* comes back.**
*
* akbasic_parse_for() and akbasic_parse_do() create the environment
* while the line is *parsed*; whether to skip is decided here, after
* parsing. So a `FOR` inside a block that is not taken pushes a scope,
* its body is skipped, and the `NEXT` that would pop it is skipped
* too. Nothing else ever will.
*
* Left behind, thirty-two of them exhausted the pool -- and inside a
* routine it was much more confusing than that: the orphan sat between
* the routine and its caller, so the `RETURN` after the block reported
* "RETURN outside the context of GOSUB" from a routine that plainly
* *was* entered by a `GOSUB`. The error named the one construct that
* was not at fault. TODO.md section 9 item 2.
*
* `loopFirstLine` is what makes this safe: it is set by both parse
* handlers and by nothing else, so it identifies the scope this very
* line pushed rather than whatever happened to be on top.
*
* **Only a block skip**, which is what the `BEND` test is for. A
* zero-iteration `FOR` skips its body the same way, and there the
* orphan is load-bearing: `tests/reference/.../nestedforloopwaiting
* forcommand.bas` nests a loop inside one that runs zero times, and
* the inner scope is what absorbs the inner `NEXT` so the outer `NEXT`
* still finds its `FOR`. Popping it there turns that case into "NEXT
* outside the context of FOR". A `FOR` inside a skipped block cannot
* be in that position, because nothing in a skipped block ever runs to
* arm a `NEXT` wait in the first place.
*/
if ( expr->leaftype == AKBASIC_LEAF_COMMAND &&
obj->environment->loopFirstLine != 0 &&
obj->environment->waitingForCommand[0] == '\0' &&
akbasic_environment_is_waiting_for(obj->environment, "BEND") &&
(strcmp(expr->identifier, "FOR") == 0 ||
strcmp(expr->identifier, "DO") == 0) ) {
PASS(errctx, akbasic_runtime_prev_environment(obj));
}
*dest = &obj->staticTrueValue;
SUCCEED_RETURN(errctx);
}