Files
akbasic/src/renumber.c
Logikoma 6896df1dff
Some checks failed
akbasic CI Build / akgl_build (push) Has been cancelled
akbasic CI Build / mutation_test (push) Has been cancelled
akbasic CI Build / coverage (push) Has been cancelled
akbasic CI Build / cmake_build (push) Has been cancelled
akbasic CI Build / sanitizers (push) Has been cancelled
Use bounded string copies when renumbering
2026-08-06 12:37:37 -04:00

526 lines
19 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 int16_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 int16_t *map = (const int16_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);
int16_t *map = obj == NULL ? NULL : obj->renumber_map;
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(obj->renumber_visited, 0, sizeof(obj->renumber_visited)));
for ( i = 0; i < AKBASIC_MAX_SOURCE_LINES; i++ ) {
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.
*/
PASS(errctx, rewrite_line(&walk, obj->source[i].code,
obj->renumber_line.code, sizeof(obj->renumber_line.code)));
obj->renumber_line.lineno = i;
/*
* 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.
*/
obj->renumber_line.numbered = true;
PASS(errctx, aksl_strncpy(obj->source[i].code, AKBASIC_MAX_LINE_LENGTH,
obj->renumber_line.code, AKBASIC_MAX_LINE_LENGTH));
obj->source[i].lineno = obj->renumber_line.lineno;
obj->source[i].numbered = obj->renumber_line.numbered;
}
/* Move the already-rewritten lines in place. The map is a partial
* permutation: a chain ends at an empty slot, while a cycle closes back
* on its starting line. A single displaced line is sufficient for both. */
for ( i = 0; i < AKBASIC_MAX_SOURCE_LINES; i++ ) {
int64_t current = i;
akbasic_SourceLine displaced;
akbasic_SourceLine next_line;
if ( map[i] < 0 || obj->renumber_visited[i] ) {
continue;
}
PASS(errctx, aksl_strncpy(displaced.code, AKBASIC_MAX_LINE_LENGTH,
obj->source[i].code, AKBASIC_MAX_LINE_LENGTH));
displaced.lineno = obj->source[i].lineno;
displaced.numbered = obj->source[i].numbered;
for ( ;; ) {
int64_t destination = map[current];
obj->renumber_visited[current] = 1;
if ( destination == i ) {
displaced.lineno = destination;
PASS(errctx, aksl_strncpy(obj->source[destination].code, AKBASIC_MAX_LINE_LENGTH,
displaced.code, AKBASIC_MAX_LINE_LENGTH));
obj->source[destination].lineno = displaced.lineno;
obj->source[destination].numbered = displaced.numbered;
break;
}
if ( map[destination] < 0 ) {
displaced.lineno = destination;
PASS(errctx, aksl_strncpy(obj->source[destination].code, AKBASIC_MAX_LINE_LENGTH,
displaced.code, AKBASIC_MAX_LINE_LENGTH));
obj->source[destination].lineno = displaced.lineno;
obj->source[destination].numbered = displaced.numbered;
PASS(errctx, aksl_memset(&obj->source[current], 0,
sizeof(obj->source[current])));
break;
}
PASS(errctx, aksl_strncpy(next_line.code, AKBASIC_MAX_LINE_LENGTH,
obj->source[destination].code, AKBASIC_MAX_LINE_LENGTH));
next_line.lineno = obj->source[destination].lineno;
next_line.numbered = obj->source[destination].numbered;
displaced.lineno = destination;
PASS(errctx, aksl_strncpy(obj->source[destination].code, AKBASIC_MAX_LINE_LENGTH,
displaced.code, AKBASIC_MAX_LINE_LENGTH));
obj->source[destination].lineno = displaced.lineno;
obj->source[destination].numbered = displaced.numbered;
PASS(errctx, aksl_memset(&obj->source[current], 0,
sizeof(obj->source[current])));
PASS(errctx, aksl_strncpy(displaced.code, AKBASIC_MAX_LINE_LENGTH,
next_line.code, AKBASIC_MAX_LINE_LENGTH));
displaced.lineno = next_line.lineno;
displaced.numbered = next_line.numbered;
current = destination;
}
}
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.
*/
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,
obj->renumber_discard, sizeof(obj->renumber_discard)));
}
/* Nothing was refused, so leave the cursor as the caller had it. */
obj->environment->lineno = entry;
SUCCEED_RETURN(errctx);
}