Files
akbasic/src/renumber.c
Tachikoma d219f80777
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m27s
akbasic CI Build / coverage (push) Failing after 3m44s
akbasic CI Build / sanitizers (push) Failing after 4m43s
akbasic CI Build / mutation_test (push) Failing after 3m45s
akbasic CI Build / akgl_build (push) Failing after 4m51s
Port onto libakstdlib 2b79aca and convert the eight bool predicates
akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2%
bypassed, against 86.4% on the same tree before this. The submodule bump
669b2b3 -> 2b79aca needed no source change of its own: the release is
drop-in for what akbasic already used.

Seven of the eight sites the earlier port left on raw libc change their own
signature rather than swallowing an error, per andrew's ruling on
libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end,
peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's
scroll/newline/putchar_at/echo_line/edit_key chain all return an
akerr_ErrorContext * and hand the answer back through an out parameter.
is_waiting_for and is_waiting_for_any are a public header change; every
call site that used one as a term in a condition hoists it into a
statement first.

verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the
comparator's signature, so there is no out parameter to report through --
which is what libakstdlib#38 concluded. It carries a comment saying so and
saying why the bypass is safe there.

Six snprintf sites stay raw because they want truncation as an answer
rather than an error, and aksl_snprintf cannot express that until
libakstdlib#34 hands the required length back. Each of the six says so at
the site. Two of them, in host.c, are a latent defect rather than a
decision: a host type name over 31 characters truncates silently and two
sharing a prefix then collide, where structtype.c refuses the same case.

DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the
PASS in it returned past CLEANUP, so a scan error left the file open.
Hoisting the loop into its own helper to convert fgets fixes it.

Refs libakstdlib#26, libakstdlib#38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00

472 lines
16 KiB
C

/**
* @file renumber.c
* @brief Walking a program's branch targets, and the two things that do it.
*
* **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.
*
* **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. 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>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
/**
* @brief Verbs whose numeric arguments name a line.
*
* `THEN` and `ELSE` are deliberately absent. Commodore BASIC lets `IF X THEN 100`
* stand for `THEN GOTO 100`; this dialect does not -- the corpus writes
* `THEN GOTO 100` -- so a number after `THEN` here is an expression, not a target.
*
* `LIST` and `DELETE` are absent too, and for a different reason: their
* arguments are ranges typed at a prompt, not references stored in a program.
* Renumbering a `DELETE` inside a listing would be rewriting something nobody
* meant as a branch.
*/
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.
*
* A target naming a line that does not exist is left alone rather than
* rewritten: `GOTO 9999` in a program with no line 9999 is already broken, and
* inventing a destination for it would hide that.
*/
static int64_t mapped(const int64_t *map, int64_t line)
{
if ( line < 0 || line >= AKBASIC_MAX_SOURCE_LINES ) {
return line;
}
return (map[line] >= 0 ? map[line] : line);
}
/**
* @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 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(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;
size_t written = 0;
while ( *p == ' ' || *p == '\t' ) {
FAIL_ZERO_RETURN(errctx, (out < limit), AKBASIC_ERR_BOUNDS,
"RENUMBER: a rewritten line does not fit");
*out = *p;
out += 1;
p += 1;
}
if ( !isdigit((unsigned char)*p) ) {
/*
* Not a number: a label, an expression, or nothing at all. Labels are
* the reason RENUMBER is survivable in the first place -- a program
* written with `GOTO DONE` needs no rewriting and cannot be broken by
* one.
*/
break;
}
while ( isdigit((unsigned char)*p) ) {
line = (line * 10) + (*p - '0');
p += 1;
}
PASS(errctx, walk->visit(walk, line, replacement, sizeof(replacement)));
/*
* The fit is decided before the copy rather than read back out of a
* return value afterwards: aksl_strcpy refuses rather than truncates,
* so the bounds check has to come first to keep the diagnosis this
* function already gives.
*/
PASS(errctx, aksl_strlen(replacement, &written));
FAIL_ZERO_RETURN(errctx, (written > 0 && out + written < limit), AKBASIC_ERR_BOUNDS,
"RENUMBER: a rewritten line does not fit");
PASS(errctx, aksl_strcpy(out, (size_t)(limit - out), replacement));
out += written;
/*
* A comma continues the list; anything else ends it. The spaces are
* skipped to find out which, and put back if the answer is "ends" --
* without that, `THEN GOTO 9 ELSE ...` came back as `THEN GOTO 30ELSE`.
*/
{
const char *afterdigits = p;
while ( *p == ' ' || *p == '\t' ) {
p += 1;
}
if ( *p != ',' ) {
p = afterdigits;
break;
}
}
FAIL_ZERO_RETURN(errctx, (out < limit), AKBASIC_ERR_BOUNDS,
"RENUMBER: a rewritten line does not fit");
*out = ',';
out += 1;
p += 1;
}
*src = p;
*dest = out;
SUCCEED_RETURN(errctx);
}
/**
* @brief Rewrite every branch target in one line of source.
*
* @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(akbasic_TargetWalk *walk, const char *code, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
const char *p = code;
char *out = dest;
const char *limit = dest + len - 1;
bool statementstart = true;
bool instring = false;
int cmp = 0;
int i = 0;
while ( *p != '\0' ) {
size_t verblen = 0;
bool matched = false;
FAIL_ZERO_RETURN(errctx, (out < limit), AKBASIC_ERR_BOUNDS,
"RENUMBER: a rewritten line does not fit");
if ( instring ) {
instring = (*p != '"');
*out = *p;
out += 1;
p += 1;
continue;
}
if ( *p == '"' ) {
/* A string literal is copied through untouched: `PRINT "GOTO 10"`. */
instring = true;
statementstart = false;
*out = *p;
out += 1;
p += 1;
continue;
}
if ( *p == ':' ) {
statementstart = true;
*out = *p;
out += 1;
p += 1;
continue;
}
if ( *p == ' ' || *p == '\t' ) {
*out = *p;
out += 1;
p += 1;
continue;
}
/*
* **Both ends, not just the trailing one.** The check below used to look
* only at the character *after* a match, so a name ending in a branch
* verb matched inside itself: `RCOLLISION(1, 1)` found `COLLISION` at its
* second character, read the `(1,` that followed as a handler argument,
* and refused the whole program with "branch to line 1, which the program
* did not number" -- naming a line that contains no branch at all.
*
* The comment below is right that the trailing check is what protects
* `GOTOX#`. Nothing protected `XGOTO#`, and `RCOLLISION` is the first
* name in the language to reach it.
*/
for ( i = 0; i < BRANCH_VERB_COUNT; i++ ) {
PASS(errctx, aksl_strlen(BRANCH_VERBS[i], &verblen));
if ( p > code && isalnum((unsigned char)p[-1]) ) {
break;
}
PASS(errctx, aksl_strncasecmp(p, BRANCH_VERBS[i], verblen, &cmp));
if ( cmp == 0 && !isalnum((unsigned char)p[verblen]) ) {
matched = true;
break;
}
}
/*
* A branch verb may appear anywhere a statement may, and `GOTO` also
* follows `THEN`, `ELSE` and `ON X` -- none of which is a statement
* start. So the match is not gated on statementstart; what protects
* `A$ = "GOTO"` is the string check above, and what protects a variable
* called `GOTOX#` is the alphanumeric check here.
*/
if ( matched ) {
FAIL_ZERO_RETURN(errctx, (out + verblen < (size_t)(limit - dest) + dest),
AKBASIC_ERR_BOUNDS, "RENUMBER: a rewritten line does not fit");
PASS(errctx, aksl_memcpy(out, p, verblen));
out += verblen;
p += verblen;
PASS(errctx, rewrite_targets(walk, &p, &out, limit));
statementstart = false;
continue;
}
/*
* COLLISION's handler is its *second* argument, so the list rewrite
* cannot be pointed at it directly. Copy the type and the comma, then
* rewrite what follows.
*/
PASS(errctx, aksl_strncasecmp(p, "COLLISION", 9, &cmp));
if ( cmp == 0 && !isalnum((unsigned char)p[9])
&& !(p > code && isalnum((unsigned char)p[-1])) ) {
PASS(errctx, aksl_memcpy(out, p, 9));
out += 9;
p += 9;
while ( *p != '\0' && *p != ',' && *p != ':' ) {
FAIL_ZERO_RETURN(errctx, (out < limit), AKBASIC_ERR_BOUNDS,
"RENUMBER: a rewritten line does not fit");
*out = *p;
out += 1;
p += 1;
}
if ( *p == ',' ) {
*out = ',';
out += 1;
p += 1;
PASS(errctx, rewrite_targets(walk, &p, &out, limit));
}
statementstart = false;
continue;
}
statementstart = false;
*out = *p;
out += 1;
p += 1;
}
*out = '\0';
(void)statementstart;
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;
int written = 0;
PASS(errctx, aksl_snprintf(&written, 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;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in renumber");
FAIL_ZERO_RETURN(errctx, (increment > 0), AKBASIC_ERR_VALUE,
"RENUMBER's increment must be positive, not %" PRId64, increment);
FAIL_ZERO_RETURN(errctx, (newstart > 0), AKBASIC_ERR_VALUE,
"RENUMBER cannot start at line %" PRId64, newstart);
for ( i = 0; i < AKBASIC_MAX_SOURCE_LINES; i++ ) {
map[i] = -1;
}
/*
* Build the whole map before touching anything. A rewrite needs to know
* where *every* line ended up, including ones it has not reached yet --
* a backward `GOTO` is as common as a forward one.
*/
for ( i = 0; i < AKBASIC_MAX_SOURCE_LINES; i++ ) {
if ( obj->source[i].code[0] == '\0' || i < oldstart ) {
continue;
}
FAIL_ZERO_RETURN(errctx, (next < AKBASIC_MAX_SOURCE_LINES), AKBASIC_ERR_BOUNDS,
"RENUMBER: line %" PRId64 " would become %" PRId64 ", past the %d line limit",
i, next, AKBASIC_MAX_SOURCE_LINES - 1);
/*
* A renumbered line must not land on a kept one. Refused rather than
* overwritten: losing a line to a renumbering is not recoverable.
*/
FAIL_ZERO_RETURN(errctx, (next >= oldstart || obj->source[next].code[0] == '\0'),
AKBASIC_ERR_VALUE,
"RENUMBER: line %" PRId64 " would become %" PRId64 ", which already exists",
i, next);
map[i] = next;
next += increment;
}
PASS(errctx, aksl_memset(rewritten, 0, sizeof(rewritten)));
for ( i = 0; i < AKBASIC_MAX_SOURCE_LINES; i++ ) {
int64_t target = 0;
if ( obj->source[i].code[0] == '\0' ) {
continue;
}
/*
* Every line is rewritten, not just the moved ones: a line before
* `oldstart` can branch into the region that moved.
*/
target = mapped(map, i);
PASS(errctx, rewrite_line(&walk, obj->source[i].code,
rewritten[target].code, sizeof(rewritten[target].code)));
rewritten[target].lineno = target;
/*
* Every line comes out numbered, whether or not it went in that way.
* Asking for numbers is what RENUMBER is, and a program that has been
* through it can be branched into by number -- which is the whole point
* of running it over source that arrived without any.
*/
rewritten[target].numbered = true;
}
PASS(errctx, aksl_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;
int written = 0;
PASS(errctx, aksl_snprintf(&written, 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);
}