Files
akbasic/tests/structure_verbs.c
Andrew Kesterson 5d33237eed 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>
2026-08-01 23:55:10 -04:00

395 lines
13 KiB
C

/**
* @file structure_verbs.c
* @brief Tests the group A verbs: DO, LOOP, BEGIN, BEND, ON and END.
*
* All of them are built on `waitingForCommand` -- a scope records the verb it is
* skipping forward to and nothing runs until that verb turns up -- so most of
* what is asserted here is *what did not run*. A block that should be skipped
* printing nothing is the whole point, and it is invisible unless the test says
* so explicitly.
*/
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "testutil.h"
/** @brief Run a program to completion in RUN mode, under an explicit step budget. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program_bounded(const char *source, int64_t steps)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, steps));
SUCCEED_RETURN(errctx);
}
/**
* @brief Run a program to completion in RUN mode, from a string.
*
* Bounded. A defect in any of these verbs is a loop that never ends, and a suite
* that hangs tells you far less than one that fails. 2000 steps is generous for
* every case here but the forty-skip one, which names its own: a *skipped* line
* is not free, and forty passes over a five-line block cost about 2700.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, run_program_bounded(source, 2000));
SUCCEED_RETURN(errctx);
}
/** @brief A condition on the top runs the body while it holds, and not at all when it does not. */
static void test_do_while(void)
{
TEST_REQUIRE_OK(run_program("10 I# = 0\n"
"20 DO WHILE I# < 3\n"
"30 PRINT I#\n"
"40 I# = I# + 1\n"
"50 LOOP\n"
"60 PRINT \"DONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n1\n2\nDONE\n");
harness_stop();
/* Already false: the body must not run once "just to see". */
TEST_REQUIRE_OK(run_program("10 I# = 5\n"
"20 DO WHILE I# < 3\n"
"30 PRINT \"NEVER\"\n"
"40 LOOP\n"
"50 PRINT \"AFTER\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "AFTER\n");
harness_stop();
}
/** @brief A condition on the bottom runs the body at least once. */
static void test_loop_until(void)
{
TEST_REQUIRE_OK(run_program("10 I# = 0\n"
"20 DO\n"
"30 PRINT I#\n"
"40 I# = I# + 1\n"
"50 LOOP UNTIL I# = 3\n"
"60 PRINT \"DONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n1\n2\nDONE\n");
harness_stop();
/*
* Already true at the bottom, so exactly one pass. This is the difference
* between a bottom-tested loop and a top-tested one, and it is why BASIC has
* both forms.
*/
TEST_REQUIRE_OK(run_program("10 I# = 99\n"
"20 DO\n"
"30 PRINT \"ONCE\"\n"
"40 LOOP UNTIL I# = 99\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "ONCE\n");
harness_stop();
}
/** @brief DO WHILE re-tests its own condition at the bottom, or it never ends. */
static void test_do_while_retests(void)
{
TEST_REQUIRE_OK(run_program("10 I# = 0\n"
"20 DO WHILE I# < 2\n"
"30 I# = I# + 1\n"
"40 LOOP\n"
"50 PRINT I#\n"));
/*
* That it printed at all is the assertion: a DO WHILE that failed to re-test
* its condition at the bottom never reaches line 50, and the bounded run
* above is what turns that into a failure rather than a hang.
*/
TEST_REQUIRE_STR(HARNESS_OUTPUT, "2\n");
harness_stop();
}
/** @brief EXIT leaves a DO loop as well as a FOR loop. */
static void test_exit_from_do(void)
{
TEST_REQUIRE_OK(run_program("10 I# = 0\n"
"20 DO\n"
"30 I# = I# + 1\n"
"40 IF I# = 2 THEN EXIT\n"
"50 LOOP\n"
"60 PRINT \"EXITED AT \" + I#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "EXITED AT 2\n");
harness_stop();
/* And EXIT outside any loop is refused rather than doing something quiet. */
TEST_REQUIRE_OK(run_program("10 EXIT\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside the context of FOR or DO") != NULL,
"a bare EXIT should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief An infinite DO...LOOP is left by GOTO, and the step bound proves it ran. */
static void test_bare_do_loop(void)
{
TEST_REQUIRE_OK(run_program("10 I# = 0\n"
"20 DO\n"
"30 I# = I# + 1\n"
"40 IF I# = 3 THEN GOTO 60\n"
"50 LOOP\n"
"60 PRINT I#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n");
harness_stop();
}
/** @brief ON picks the nth target, one-based, and falls through when there is none. */
static void test_on_goto(void)
{
TEST_REQUIRE_OK(run_program("10 X# = 2\n"
"20 ON X# GOTO 100, 200\n"
"30 PRINT \"FELL THROUGH\"\n"
"40 END\n"
"100 PRINT \"ONE\"\n"
"110 END\n"
"200 PRINT \"TWO\"\n"
"210 END\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "TWO\n");
harness_stop();
/*
* Out of range is not an error. BASIC 7.0 falls through to the next
* statement, which is what lets `ON X GOTO ...` be written without a bounds
* check in front of it. Zero and negative fall through the same way.
*/
TEST_REQUIRE_OK(run_program("10 X# = 9\n"
"20 ON X# GOTO 100, 200\n"
"30 PRINT \"FELL THROUGH\"\n"
"40 END\n"
"100 PRINT \"ONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "FELL THROUGH\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 X# = 0\n"
"20 ON X# GOTO 100\n"
"30 PRINT \"FELL THROUGH\"\n"
"40 END\n"
"100 PRINT \"ONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "FELL THROUGH\n");
harness_stop();
}
/** @brief ON ... GOSUB returns to the line after the ON. */
static void test_on_gosub(void)
{
TEST_REQUIRE_OK(run_program("10 X# = 1\n"
"20 ON X# GOSUB 100, 200\n"
"30 PRINT \"BACK\"\n"
"40 END\n"
"100 PRINT \"SUB ONE\"\n"
"110 RETURN\n"
"200 PRINT \"SUB TWO\"\n"
"210 RETURN\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "SUB ONE\nBACK\n");
harness_stop();
}
/** @brief A label is a legal ON target, since it evaluates to a line number. */
static void test_on_label(void)
{
TEST_REQUIRE_OK(run_program("10 X# = 1\n"
"20 ON X# GOTO TARGET\n"
"30 PRINT \"FELL THROUGH\"\n"
"40 END\n"
"100 LABEL TARGET\n"
"110 PRINT \"AT LABEL\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "AT LABEL\n");
harness_stop();
}
/** @brief BEGIN/BEND makes an IF span lines, and the arm not taken skips all of them. */
static void test_begin_bend(void)
{
TEST_REQUIRE_OK(run_program("10 A# = 1\n"
"20 IF A# = 1 THEN BEGIN\n"
"30 PRINT \"IN BLOCK\"\n"
"40 PRINT \"STILL IN\"\n"
"50 BEND\n"
"60 PRINT \"AFTER\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "IN BLOCK\nSTILL IN\nAFTER\n");
harness_stop();
/*
* The skipped case is the one that needs the wait. `skiprestofline` only
* reaches the end of the IF's own line; without arming a skip to BEND the
* block's lines would run as ordinary top-level lines.
*/
TEST_REQUIRE_OK(run_program("10 A# = 0\n"
"20 IF A# = 1 THEN BEGIN\n"
"30 PRINT \"NOT SEEN\"\n"
"40 PRINT \"ALSO NOT SEEN\"\n"
"50 BEND\n"
"60 PRINT \"AFTER\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "AFTER\n");
harness_stop();
}
/** @brief END stops the program without quitting a REPL session. */
static void test_end(void)
{
TEST_REQUIRE_OK(run_program("10 PRINT \"BEFORE\"\n"
"20 END\n"
"30 PRINT \"AFTER\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "BEFORE\n");
TEST_REQUIRE_INT(HARNESS_RUNTIME.mode, AKBASIC_MODE_QUIT);
harness_stop();
/*
* Started from a REPL, END goes back to the prompt rather than ending the
* interpreter -- which is the whole difference between END and QUIT.
*/
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT \"HI\"\n20 END\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_REPL));
HARNESS_RUNTIME.mode = AKBASIC_MODE_RUN;
HARNESS_RUNTIME.environment->nextline = 0;
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 40));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "HI") != NULL,
"the program should have run, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "READY") != NULL,
"END from a REPL session should return to the prompt, got \"%s\"",
HARNESS_OUTPUT);
harness_stop();
}
/** @brief Nested DO loops each keep their own condition and unwind in order. */
static void test_nested_do(void)
{
TEST_REQUIRE_OK(run_program("10 I# = 0\n"
"20 DO WHILE I# < 2\n"
"30 J# = 0\n"
"40 DO WHILE J# < 2\n"
"50 PRINT I# * 10 + J#\n"
"60 J# = J# + 1\n"
"70 LOOP\n"
"80 I# = I# + 1\n"
"90 LOOP\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n1\n10\n11\n");
harness_stop();
}
/** @brief LOOP without a DO is refused rather than silently doing nothing. */
static void test_loop_without_do(void)
{
TEST_REQUIRE_OK(run_program("10 LOOP\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside the context of DO") != NULL,
"a bare LOOP should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief A block that is not taken gives back the scope its loop pushed.
*
* `FOR` and `DO` create their environment while the line is *parsed*; the skip
* is decided when it is *evaluated*. So a loop inside a block that is not taken
* used to push a scope whose `NEXT` or `LOOP` was skipped along with it, and
* nothing ever popped it.
*
* Inside a routine that was much worse than a leak. 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. TODO.md section 9
* item 2, found by writing a game against the interpreter.
*/
static void test_skipped_block_releases_its_loop_scope(void)
{
/* Both loop forms, because both parse handlers push. */
TEST_REQUIRE_OK(run_program("10 T# = 0\n"
"20 GOSUB DOIT\n"
"30 PRINT \"CAME BACK\"\n"
"40 END\n"
"50 LABEL DOIT\n"
"60 IF 1 = 0 THEN BEGIN\n"
"70 FOR I# = 0 TO 2\n"
"80 T# = T# + 1\n"
"90 NEXT I#\n"
"100 BEND\n"
"110 RETURN\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "CAME BACK\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 T# = 0\n"
"20 GOSUB DOIT\n"
"30 PRINT \"CAME BACK\"\n"
"40 END\n"
"50 LABEL DOIT\n"
"60 IF 1 = 0 THEN BEGIN\n"
"70 DO\n"
"80 T# = T# + 1\n"
"90 LOOP UNTIL T# > 2\n"
"100 BEND\n"
"110 RETURN\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "CAME BACK\n");
harness_stop();
/*
* At the top level it announced itself as pool exhaustion instead, after
* thirty-two skips -- AKBASIC_MAX_ENVIRONMENTS. Forty here, so a fix that
* merely raised the bound would not pass.
*/
TEST_REQUIRE_OK(run_program_bounded("10 N# = 0\n"
"20 LABEL AGAIN\n"
"30 N# = N# + 1\n"
"40 IF 1 = 0 THEN BEGIN\n"
"50 FOR I# = 0 TO 2\n"
"60 N# = N# + 1000\n"
"70 NEXT I#\n"
"80 BEND\n"
"90 IF N# < 40 THEN GOTO AGAIN\n"
"100 PRINT \"OK \" + N#\n", 6000));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "OK 40\n");
harness_stop();
}
/**
* @brief A zero-iteration loop still keeps the scope its inner loop pushed.
*
* The counter-case, and the reason the fix above tests for a `BEND` wait rather
* than for any wait at all. A `FOR` that runs zero times skips its body by the
* same mechanism, and *there* the orphan is load-bearing: it is what absorbs the
* inner `NEXT`, so the outer `NEXT` still finds its own `FOR`. Releasing it
* turns this into "NEXT outside the context of FOR".
*
* tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas is the
* golden case that caught exactly that, and this is the same shape asserted
* where a reader of this file will see it.
*/
static void test_zero_iteration_loop_keeps_its_inner_scope(void)
{
TEST_REQUIRE_OK(run_program("10 FOR I# = 1 TO 0\n"
"20 FOR J# = 2 TO 4\n"
"30 PRINT \"THE BODY MUST NOT RUN\"\n"
"40 NEXT J#\n"
"50 NEXT I#\n"
"60 PRINT \"SUCCESS\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "SUCCESS\n");
harness_stop();
}
int main(void)
{
test_do_while();
test_loop_until();
test_do_while_retests();
test_exit_from_do();
test_bare_do_loop();
test_on_goto();
test_on_gosub();
test_on_label();
test_begin_bend();
test_end();
test_nested_do();
test_loop_without_do();
test_skipped_block_releases_its_loop_scope();
test_zero_iteration_loop_keeps_its_inner_scope();
return akbasic_test_failures;
}