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>
This commit is contained in:
2026-08-01 16:38:54 -04:00
parent 8bc253ebbf
commit 6273be580a
7 changed files with 341 additions and 20 deletions

View File

@@ -1,18 +1,26 @@
/**
* @file renumber.c
* @brief RENUMBER: move every line, and rewrite every reference to one.
* @brief Walking a program's branch targets, and the two things that do it.
*
* Moving the lines is easy -- source is filed under its line number in
* `source[]`, so it is a permutation. Rewriting every `GOTO`, `GOSUB`, `RUN`,
* `RESTORE`, `TRAP` and `COLLISION` target to match is the job, and it is why
* this was deferred rather than written early: a `RENUMBER` that moved lines
* without fixing their references would silently break every branch in the
* program, which is worse than not having the verb at all.
* **RENUMBER** moves every line and rewrites every reference to one. Moving the
* lines is easy -- source is filed under its line number in `source[]`, so it is
* a permutation. Rewriting every `GOTO`, `GOSUB`, `RUN`, `RESTORE`, `TRAP` and
* `COLLISION` target to match is the job, and it is why this was deferred rather
* than written early: a `RENUMBER` that moved lines without fixing their
* references would silently break every branch in the program, which is worse
* than not having the verb at all.
*
* The rewrite is textual, and the only thing it has to understand about the rest
* of the language is where a string literal starts and stops -- `PRINT "GOTO 10"`
* **akbasic_runtime_check_targets()** walks the same targets without moving
* anything, refusing a program that branches by number to a line the program
* never numbered. It shares this file because it needs exactly the same walk,
* and a second copy of it would drift.
*
* The walk is textual, and the only thing it has to understand about the rest of
* the language is where a string literal starts and stops -- `PRINT "GOTO 10"`
* must come through untouched. That is the same statement-walking the label and
* DATA prescans do.
* DATA prescans do. What the two callers differ in is one function pointer:
* given a target, say what text should stand in its place, and raise if it is not
* acceptable.
*/
#include <ctype.h>
@@ -43,6 +51,21 @@ static const char *BRANCH_VERBS[] = { "GOTO", "GOSUB", "RUN", "RESTORE", "TRAP"
/** @brief How many entries #BRANCH_VERBS has. */
#define BRANCH_VERB_COUNT ((int)(sizeof(BRANCH_VERBS) / sizeof(BRANCH_VERBS[0])))
/**
* @brief What a walk does with each numeric branch target it finds.
*
* The one thing RENUMBER and the target check differ in. `visit` is handed a
* target and writes into @p dest the text that should stand in its place --
* the new number for RENUMBER, the same number for a check -- and may raise to
* refuse the line outright.
*/
typedef struct akbasic_TargetWalk akbasic_TargetWalk;
struct akbasic_TargetWalk
{
void *self;
akerr_ErrorContext *(*visit)(akbasic_TargetWalk *walk, int64_t target, char *dest, size_t len);
};
/**
* @brief Where a line moved to, or the line itself when it did not move.
*
@@ -59,25 +82,26 @@ static int64_t mapped(const int64_t *map, int64_t line)
}
/**
* @brief Copy a run of comma-separated line numbers, rewriting each.
* @brief Copy a run of comma-separated line numbers, visiting each.
*
* `ON X GOTO 100, 200, 300` is why this takes a list rather than one number: the
* targets follow the `GOTO`, so handling `GOTO` handles `ON` for free.
*
* @param map Old line to new line.
* @param walk What to substitute for each target, and whether to allow it.
* @param src Reads from here; advanced past what was consumed.
* @param dest Writes to here; advanced past what was written.
* @param limit One past the last byte @p dest may write.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKBASIC_ERR_BOUNDS When the rewritten line does not fit.
*/
static akerr_ErrorContext *rewrite_targets(const int64_t *map, const char **src, char **dest, const char *limit)
static akerr_ErrorContext *rewrite_targets(akbasic_TargetWalk *walk, const char **src, char **dest, const char *limit)
{
PREPARE_ERROR(errctx);
const char *p = *src;
char *out = *dest;
for ( ;; ) {
char replacement[32];
int64_t line = 0;
int written = 0;
@@ -101,7 +125,8 @@ static akerr_ErrorContext *rewrite_targets(const int64_t *map, const char **src,
line = (line * 10) + (*p - '0');
p += 1;
}
written = snprintf(out, (size_t)(limit - out), "%" PRId64, mapped(map, line));
PASS(errctx, walk->visit(walk, line, replacement, sizeof(replacement)));
written = snprintf(out, (size_t)(limit - out), "%s", replacement);
FAIL_ZERO_RETURN(errctx, (written > 0 && out + written < limit), AKBASIC_ERR_BOUNDS,
"RENUMBER: a rewritten line does not fit");
out += written;
@@ -136,14 +161,14 @@ static akerr_ErrorContext *rewrite_targets(const int64_t *map, const char **src,
/**
* @brief Rewrite every branch target in one line of source.
*
* @param map Old line to new line.
* @param walk What to substitute for each target, and whether to allow it.
* @param code The line to read.
* @param dest Where the rewritten line goes.
* @param len Capacity of @p dest.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKBASIC_ERR_BOUNDS When the rewritten line does not fit.
*/
static akerr_ErrorContext *rewrite_line(const int64_t *map, const char *code, char *dest, size_t len)
static akerr_ErrorContext *rewrite_line(akbasic_TargetWalk *walk, const char *code, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
const char *p = code;
@@ -210,7 +235,7 @@ static akerr_ErrorContext *rewrite_line(const int64_t *map, const char *code, ch
memcpy(out, p, verblen);
out += verblen;
p += verblen;
PASS(errctx, rewrite_targets(map, &p, &out, limit));
PASS(errctx, rewrite_targets(walk, &p, &out, limit));
statementstart = false;
continue;
}
@@ -235,7 +260,7 @@ static akerr_ErrorContext *rewrite_line(const int64_t *map, const char *code, ch
*out = ',';
out += 1;
p += 1;
PASS(errctx, rewrite_targets(map, &p, &out, limit));
PASS(errctx, rewrite_targets(walk, &p, &out, limit));
}
statementstart = false;
continue;
@@ -251,11 +276,24 @@ static akerr_ErrorContext *rewrite_line(const int64_t *map, const char *code, ch
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------- RENUMBER -- */
/** @brief RENUMBER's visitor: substitute the number the line moved to. */
static akerr_ErrorContext *visit_renumber(akbasic_TargetWalk *walk, int64_t target, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
const int64_t *map = (const int64_t *)walk->self;
snprintf(dest, len, "%" PRId64, mapped(map, target));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_renumber(akbasic_Runtime *obj, int64_t newstart, int64_t increment, int64_t oldstart)
{
PREPARE_ERROR(errctx);
static int64_t map[AKBASIC_MAX_SOURCE_LINES];
static akbasic_SourceLine rewritten[AKBASIC_MAX_SOURCE_LINES];
akbasic_TargetWalk walk = { map, visit_renumber };
int64_t next = newstart;
int64_t i = 0;
@@ -305,7 +343,7 @@ akerr_ErrorContext *akbasic_renumber(akbasic_Runtime *obj, int64_t newstart, int
* `oldstart` can branch into the region that moved.
*/
target = mapped(map, i);
PASS(errctx, rewrite_line(map, obj->source[i].code,
PASS(errctx, rewrite_line(&walk, obj->source[i].code,
rewritten[target].code, sizeof(rewritten[target].code)));
rewritten[target].lineno = target;
/*
@@ -320,3 +358,88 @@ akerr_ErrorContext *akbasic_renumber(akbasic_Runtime *obj, int64_t newstart, int
memcpy(obj->source, rewritten, sizeof(obj->source));
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------- the target prescan -- */
/** @brief What visit_check() needs to name the line it refused. */
typedef struct
{
const akbasic_Runtime *runtime;
int64_t lineno;
} CheckState;
/**
* @brief The prescan's visitor: allow the target, or refuse the program.
*
* A number that names a line the program never numbered is refused. In a script
* written without line numbers `GOTO 100` finds the hundredth line and branches
* there -- plausible, silent, and wrong, which is the worst kind of defect to be
* handed at run time.
*
* A target naming an *empty* slot is allowed through, for the same reason
* mapped() leaves one alone: `GOTO 9999` in a program with no line 9999 is
* already broken, and refusing it here would be inventing a rule about lines
* that do not exist. The substitution is the number itself, because nothing is
* being rewritten.
*/
static akerr_ErrorContext *visit_check(akbasic_TargetWalk *walk, int64_t target, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
CheckState *state = (CheckState *)walk->self;
snprintf(dest, len, "%" PRId64, target);
if ( target >= 0 && target < AKBASIC_MAX_SOURCE_LINES ) {
FAIL_NONZERO_RETURN(errctx,
(state->runtime->source[target].code[0] != '\0'
&& !state->runtime->source[target].numbered),
AKBASIC_ERR_SYNTAX,
"Line %" PRId64 ": branch to line %" PRId64
", which the program did not number. Branch by LABEL, or RENUMBER first",
state->lineno, target);
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_runtime_check_targets(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
/*
* Static for the same reason akbasic_renumber()'s buffers are: a line's worth
* of rewrite does not belong on a default stack alongside the rest of a
* step(). Nothing is read back out of it -- the walk needs somewhere to put
* the text it would have written, and this is it.
*/
static char discard[AKBASIC_MAX_LINE_LENGTH * 2];
CheckState state = { NULL, 0 };
akbasic_TargetWalk walk = { &state, visit_check };
int64_t entry = 0;
int64_t i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in check_targets");
state.runtime = obj;
entry = obj->environment->lineno;
for ( i = 0; i < AKBASIC_MAX_SOURCE_LINES; i++ ) {
if ( obj->source[i].code[0] == '\0' ) {
continue;
}
state.lineno = i;
/*
* Point the runtime at the line being walked, so the `? N :` prefix
* akbasic_runtime_error() builds from environment->lineno names the
* offending line rather than whichever one the loader stopped on. The
* message repeats the number because the other three prescans do not do
* this and get it wrong -- see TODO.md.
*
* PASS inside the loop, never CATCH: CATCH expands to a break, which
* would leave this loop rather than an enclosing ATTEMPT. That also means
* the cursor is deliberately *not* restored on the failure path, which is
* the whole point of setting it.
*/
obj->environment->lineno = i;
PASS(errctx, rewrite_line(&walk, obj->source[i].code, discard, sizeof(discard)));
}
/* Nothing was refused, so leave the cursor as the caller had it. */
obj->environment->lineno = entry;
SUCCEED_RETURN(errctx);
}

View File

@@ -380,7 +380,7 @@ akerr_ErrorContext *akbasic_runtime_set_mode(akbasic_Runtime *obj, int mode)
*/
if ( obj->mode == AKBASIC_MODE_RUN && obj->environment != NULL ) {
/*
* All three prescans, and all three inside one ATTEMPT.
* All four prescans, and all four inside one ATTEMPT.
*
* **A prescan failure is the program's mistake, not the host's**, so it
* has to leave here as a BASIC error line rather than as a raised
@@ -417,6 +417,16 @@ akerr_ErrorContext *akbasic_runtime_set_mode(akbasic_Runtime *obj, int mode)
* skipped the lines that declared it.
*/
CATCH(errctx, akbasic_structtype_scan(obj));
/*
* Then the branch targets, last because it is the only one that reads
* nothing into the runtime -- it only refuses. A loaded line may be
* *given* its number rather than carry one, so `GOTO 100` in a script
* written without numbers would otherwise find the hundredth line and
* branch there, which is plausible, silent and wrong. Said here rather
* than at the branch, because before the program runs is earlier and
* every path into a run comes through this function.
*/
CATCH(errctx, akbasic_runtime_check_targets(obj));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {