Files
akbasic/tests/unnumbered.c
Andrew Kesterson 6273be580a Refuse a branch to a line the program did not number
GOTO 100 in a script written without line numbers finds the hundredth line
and branches there. Silent, plausible and wrong: the test for it loops
forever printing the second line when the check is removed.

akbasic_runtime_check_targets() is a fourth prescan beside the label, DATA
and TYPE ones, run on every entry into MODE_RUN -- the earliest the check
can be made and the only place all four ways a program arrives pass
through. A target naming an empty line is still allowed, for the same
reason RENUMBER leaves one alone, and a fully numbered program is
unaffected, which is every program that existed before this.

It shares RENUMBER's walk rather than repeating it. renumber.c grows an
akbasic_TargetWalk -- a self pointer and a visit function -- and
rewrite_line() takes one. RENUMBER's visitor substitutes the number a line
moved to; the check's substitutes the number unchanged and raises. One
walk, so the two cannot disagree about what a branch target is.

The check points environment->lineno at the line being walked so the "? N :"
prefix names the offending line. The other three prescans do not and report
whichever line the loader stopped on; TODO.md section 5 item 64 records it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:38:54 -04:00

357 lines
13 KiB
C

/**
* @file unnumbered.c
* @brief Programs loaded without line numbers.
*
* A script written against `LABEL` and `GOTO NAME` never names a line number, so
* the numbers it used to be forced to carry were decoration. A loaded line that
* arrives without one now gets the slot after the last line filed, which is the
* rule `akbasic_runtime_file_line()` holds for all three loading paths.
*
* What is asserted here is the seam, not the language: which slot a line lands
* in, that both spellings execute identically, that the prompt's rule is
* untouched, and that a collision is refused rather than losing a line.
*/
#include <stdio.h>
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "testutil.h"
/** @brief Load a program from a string and run it to completion. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
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, 0));
SUCCEED_RETURN(errctx);
}
/** @brief Read a program through the sink, the way the file driver does. */
static akerr_ErrorContext AKERR_NOIGNORE *runstream_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUNSTREAM));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
SUCCEED_RETURN(errctx);
}
/** @brief A program with no numbers at all runs top to bottom. */
static void test_runs_in_order(void)
{
TEST_REQUIRE_OK(run_program("PRINT \"ONE\"\n"
"PRINT \"TWO\"\n"
"PRINT \"THREE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "ONE\nTWO\nTHREE\n");
/* Slot 0 is left empty and the program starts at 1. */
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[0].code, "");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[1].code, "PRINT \"ONE\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[2].code, "PRINT \"TWO\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[3].code, "PRINT \"THREE\"");
/* And every one of them knows it was assigned rather than written. */
TEST_REQUIRE(!HARNESS_RUNTIME.source[1].numbered, "line 1 should not be marked numbered");
TEST_REQUIRE(!HARNESS_RUNTIME.source[3].numbered, "line 3 should not be marked numbered");
harness_stop();
}
/**
* @brief Two unnumbered lines in a row are two lines.
*
* This is the defect the feature replaces, stated on its own: the second used to
* be filed under the cursor unchanged, on top of the first, with no error and no
* output. A test that only checked the *last* line would have passed throughout.
*/
static void test_second_line_does_not_replace_the_first(void)
{
TEST_REQUIRE_OK(run_program("PRINT \"FIRST\"\n"
"PRINT \"SECOND\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "FIRST\nSECOND\n");
harness_stop();
}
/** @brief A blank line is whitespace, and costs no slot. */
static void test_blank_lines_cost_nothing(void)
{
TEST_REQUIRE_OK(run_program("PRINT \"ONE\"\n"
"\n"
"\n"
"PRINT \"TWO\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "ONE\nTWO\n");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[2].code, "PRINT \"TWO\"");
harness_stop();
}
/**
* @brief A numbered line moves the cursor; unnumbered ones follow from there.
*
* Execution order is slot order, not file order, which is the whole reason the
* two can be mixed at all: `50 PRINT "C"` runs before `100 PRINT "A"` however
* they were written down.
*/
static void test_mixed_numbering(void)
{
TEST_REQUIRE_OK(run_program("100 PRINT \"A\"\n"
"PRINT \"B\"\n"
"50 PRINT \"C\"\n"
"PRINT \"D\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "C\nD\nA\nB\n");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[50].code, "PRINT \"C\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[51].code, "PRINT \"D\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[100].code, "PRINT \"A\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[101].code, "PRINT \"B\"");
/* The written numbers are marked as such; the two that followed are not. */
TEST_REQUIRE(HARNESS_RUNTIME.source[50].numbered, "line 50 was written and should be marked");
TEST_REQUIRE(HARNESS_RUNTIME.source[100].numbered, "line 100 was written and should be marked");
TEST_REQUIRE(!HARNESS_RUNTIME.source[51].numbered, "line 51 was assigned, not written");
TEST_REQUIRE(!HARNESS_RUNTIME.source[101].numbered, "line 101 was assigned, not written");
harness_stop();
}
/** @brief Branching by label needs no numbers anywhere. */
static void test_label_branching(void)
{
TEST_REQUIRE_OK(run_program("GOSUB GREET\n"
"GOTO DONE\n"
"PRINT \"NOT REACHED\"\n"
"LABEL GREET\n"
"PRINT \"HELLO\"\n"
"RETURN\n"
"LABEL DONE\n"
"PRINT \"BYE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "HELLO\nBYE\n");
harness_stop();
}
/**
* @brief A collision involving an assigned number is refused, not overwritten.
*
* `1 PRINT "C"` names a slot two unnumbered lines have already been given. Losing
* one of them to that is not recoverable, so the load fails instead.
*/
static void test_collision_is_refused(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_STATUS(akbasic_runtime_load(&HARNESS_RUNTIME,
"PRINT \"A\"\n"
"PRINT \"B\"\n"
"1 PRINT \"C\"\n"),
AKBASIC_ERR_BOUNDS);
harness_stop();
/* The mirror image: an assigned line landing on one already written. */
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_STATUS(akbasic_runtime_load(&HARNESS_RUNTIME,
"2 PRINT \"A\"\n"
"1 PRINT \"B\"\n"
"PRINT \"C\"\n"),
AKBASIC_ERR_BOUNDS);
harness_stop();
}
/** @brief Two lines carrying the same written number still keep the last. */
static void test_duplicate_written_numbers_still_replace(void)
{
TEST_REQUIRE_OK(run_program("10 PRINT \"OLD\"\n"
"10 PRINT \"NEW\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "NEW\n");
harness_stop();
}
/** @brief The same program through the sink lands in the same slots. */
static void test_runstream_assigns_the_same_slots(void)
{
TEST_REQUIRE_OK(runstream_program("PRINT \"ONE\"\n"
"PRINT \"TWO\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "ONE\nTWO\n");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[1].code, "PRINT \"ONE\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[2].code, "PRINT \"TWO\"");
harness_stop();
}
/**
* @brief A file ending in a blank line keeps its last line.
*
* `tests/reference/language/arithmetic/integer.bas` is this shape, and both this
* interpreter and the reference used to erase the last line before running it --
* the blank line was filed under the cursor, which for a line with no number of
* its own is the number of the line before it.
*/
static void test_trailing_blank_line_keeps_the_last_line(void)
{
TEST_REQUIRE_OK(runstream_program("PRINT \"ONE\"\n"
"PRINT \"LAST\"\n"
"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "ONE\nLAST\n");
harness_stop();
}
/**
* @brief The prompt still tells a program line from a statement by its number.
*
* The one rule the feature does not touch. `PRINT 2 + 2` typed at a prompt must
* answer 4 rather than quietly becoming line 1 of a program.
*/
static void test_prompt_still_runs_direct_mode(void)
{
TEST_REQUIRE_OK(harness_start("PRINT 2 + 2\n10 PRINT \"FILED\"\nLIST\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_REPL));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
/* 4 came out now; the numbered line went into the program and LIST found it. */
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "4\n") != NULL,
"PRINT 2 + 2 at the prompt should have answered 4, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "10 PRINT \"FILED\"") != NULL,
"the numbered line should have been filed and listed, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[1].code, "");
harness_stop();
}
/**
* @brief Branching by number to a line nobody numbered is refused before the run.
*
* `GOTO 2` in a program with no line numbers finds the second line and branches
* there. The prescan says so instead, and says it before any line executes -- so
* the program produces the error and nothing else, not the error partway through
* its output.
*/
static void test_numeric_branch_to_an_assigned_line_is_refused(void)
{
TEST_REQUIRE_OK(run_program("PRINT \"A\"\n"
"PRINT \"B\"\n"
"GOTO 2\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "did not number") != NULL,
"expected a refusal naming the unnumbered target, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "A\n") == NULL,
"the program should not have run at all, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* The refusal names the offending line, not wherever the loader stopped. */
TEST_REQUIRE_OK(run_program("GOTO 4\n"
"PRINT \"B\"\n"
"PRINT \"C\"\n"
"PRINT \"D\"\n"
"PRINT \"E\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "? 1 :") != NULL,
"expected the error to name line 1, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief A written number is still a legal target, in a mixed program too. */
static void test_numeric_branch_to_a_written_line_is_allowed(void)
{
TEST_REQUIRE_OK(run_program("10 PRINT \"A\"\n"
"20 IF A# = 0 THEN A# = 1 : GOTO 10\n"
"30 PRINT \"OK\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "A\nA\nOK\n");
harness_stop();
/* Mixed: line 10 was written, so a branch to it stands. */
TEST_REQUIRE_OK(run_program("10 PRINT \"A\"\n"
"PRINT \"B\"\n"
"20 IF A# = 0 THEN A# = 1 : GOTO 10\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "A\nB\nA\nB\n");
harness_stop();
}
/** @brief A branch to an empty line is left alone, as RENUMBER leaves it alone. */
static void test_numeric_branch_to_an_empty_line_is_allowed(void)
{
TEST_REQUIRE_OK(run_program("PRINT \"A\"\n"
"GOTO 500\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "A\n");
harness_stop();
}
/** @brief A label target never reaches the check, so it cannot be refused by it. */
static void test_label_targets_are_never_refused(void)
{
TEST_REQUIRE_OK(run_program("GOTO DONE\n"
"PRINT \"SKIPPED\"\n"
"LABEL DONE\n"
"PRINT \"OK\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "OK\n");
harness_stop();
}
/** @brief A number inside a string literal is not a branch target. */
static void test_a_number_in_a_string_is_not_a_target(void)
{
TEST_REQUIRE_OK(run_program("PRINT \"A\"\n"
"PRINT \"GOTO 2\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "A\nGOTO 2\n");
harness_stop();
}
/**
* @brief RENUMBER makes a numberless program branchable by number again.
*
* It moves line 3 to line 30 *and* rewrites `GOTO 3` to `GOTO 30`, and every line
* it touches comes out numbered -- so the branch the check refused a moment ago
* is now naming a number the program owns. That is the remedy the refusal names.
*/
static void test_renumber_makes_numeric_branches_legal(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"PRINT \"A\"\n"
"GOTO 3\n"
"PRINT \"B\"\n"));
/* Before: line 3 was assigned a number, so branching to it is refused. */
TEST_REQUIRE_STATUS(akbasic_runtime_check_targets(&HARNESS_RUNTIME), AKBASIC_ERR_SYNTAX);
TEST_REQUIRE_OK(akbasic_renumber(&HARNESS_RUNTIME, 10, 10, 0));
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[20].code, "GOTO 30");
TEST_REQUIRE_OK(akbasic_runtime_check_targets(&HARNESS_RUNTIME));
harness_stop();
}
/** @brief RENUMBER numbers everything, which is what asking for it means. */
static void test_renumber_marks_every_line_numbered(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"PRINT \"ONE\"\n"
"PRINT \"TWO\"\n"));
TEST_REQUIRE(!HARNESS_RUNTIME.source[1].numbered, "line 1 starts out assigned");
TEST_REQUIRE_OK(akbasic_renumber(&HARNESS_RUNTIME, 10, 10, 0));
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[10].code, "PRINT \"ONE\"");
TEST_REQUIRE_STR(HARNESS_RUNTIME.source[20].code, "PRINT \"TWO\"");
TEST_REQUIRE(HARNESS_RUNTIME.source[10].numbered, "RENUMBER should mark line 10 numbered");
TEST_REQUIRE(HARNESS_RUNTIME.source[20].numbered, "RENUMBER should mark line 20 numbered");
harness_stop();
}
int main(void)
{
test_runs_in_order();
test_second_line_does_not_replace_the_first();
test_blank_lines_cost_nothing();
test_mixed_numbering();
test_label_branching();
test_collision_is_refused();
test_duplicate_written_numbers_still_replace();
test_runstream_assigns_the_same_slots();
test_trailing_blank_line_keeps_the_last_line();
test_prompt_still_runs_direct_mode();
test_numeric_branch_to_an_assigned_line_is_refused();
test_numeric_branch_to_a_written_line_is_allowed();
test_numeric_branch_to_an_empty_line_is_allowed();
test_label_targets_are_never_refused();
test_a_number_in_a_string_is_not_a_target();
test_renumber_makes_numeric_branches_legal();
test_renumber_marks_every_line_numbered();
return akbasic_test_failures;
}