Finish the language: every remaining verb group, and the defects that blocked them

Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.

Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.

Writing the tests turned up eight defects nobody had listed. Seven are fixed:

  IF A = 2 THEN was a parse error; only == worked
  IF ... AND ... was a parse error, because a condition parsed as one relation
  IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
  EXIT before any NEXT restarted the program and exhausted the variable pool
  READ never found a DATA line above it, and swallowed the lines between
  PRINT 2 + 2 at the prompt was filed as program text instead of answering
  a short read discarded its bytes, so COPY produced empty files
  every verb taking an argument list said "peek() returned nil token!" on none

The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.

Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.

Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.

94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
This commit is contained in:
2026-07-31 21:50:37 -04:00
parent 1f822bd96b
commit 4e7d2cff6c
87 changed files with 11024 additions and 325 deletions

View File

@@ -19,11 +19,17 @@
#include <akerror.h>
#include <akbasic/audio.h>
#include <akbasic/console.h>
#include <akbasic/data.h>
#include <akbasic/disk.h>
#include <akbasic/environment.h>
#include <akbasic/format.h>
#include <akbasic/grammar.h>
#include <akbasic/graphics.h>
#include <akbasic/input.h>
#include <akbasic/sink.h>
#include <akbasic/sprite.h>
#include <akbasic/symtab.h>
#include <akbasic/types.h>
#include <akbasic/value.h>
#include <akbasic/variable.h>
@@ -44,6 +50,39 @@ typedef struct
int64_t lineno;
} akbasic_SourceLine;
/**
* @brief What can interrupt a running program and send it into a handler.
*
* The numbering is COLLISION's own argument minus one, so `COLLISION 1` arms
* slot 0. The two collision types this interpreter refuses still hold slots:
* dropping them would make the arithmetic a lookup table for no gain, and a
* later release that implements them wants the same numbers.
*/
typedef enum
{
AKBASIC_INTERRUPT_SPRITE = 0, /** COLLISION 1 -- sprite met sprite */
AKBASIC_INTERRUPT_BACKGROUND, /** COLLISION 2 -- sprite met background; refused */
AKBASIC_INTERRUPT_LIGHTPEN, /** COLLISION 3 -- light pen; refused */
AKBASIC_INTERRUPT_ERROR, /** TRAP -- a BASIC error; group C, not yet armed by anything */
AKBASIC_MAX_INTERRUPTS
} akbasic_InterruptSource;
/**
* @brief One armed interrupt: where its handler is, and whether it is due.
*
* The target is held as a line number *or* a label name, and a label is resolved
* when the interrupt fires rather than when it is armed. A label may be re-filed
* by its own LABEL statement at any point, so resolving late is what lets the
* later spelling win.
*/
typedef struct
{
bool armed;
bool pending;
int64_t line; /* the numeric target; 0 when a label was given */
char label[AKBASIC_SYMTAB_MAX_KEY]; /* the label target; empty when a number was given */
} akbasic_Interrupt;
/** @brief A user-defined subroutine or single-expression function. */
typedef struct
{
@@ -95,6 +134,7 @@ typedef struct akbasic_Runtime
akbasic_GraphicsBackend *graphics;
akbasic_AudioBackend *audio;
akbasic_InputBackend *input;
akbasic_SpriteBackend *sprites;
/*
* The graphics verbs' own state -- mode, color-source bindings, pixel cursor
@@ -114,6 +154,59 @@ typedef struct akbasic_Runtime
/* GETKEY's hold on the step loop. Same reasoning again: it is the program's. */
akbasic_InputState input_state;
/*
* The eight sprites, their positions and their collision bits. Same
* reasoning as gfx and audio_state, and one more: RSPPOS and RSPRITE read
* this rather than asking the device, so they answer correctly with no
* device attached at all.
*/
akbasic_SpriteState sprite_state;
/* PUDEF's fill characters, which PRINT USING pads and punctuates with. */
akbasic_FormatState format_state;
/* SLEEP's and WAIT's hold on the step loop, and KEY's macros. */
akbasic_ConsoleState console_state;
/* Every DATA item in the program, and how far READ has got along it. */
akbasic_DataState data_state;
/* The open file channels DOPEN hands out. */
akbasic_DiskState disk_state;
/*
* Where the running program was loaded from, or empty when it did not come
* from a file -- a REPL session, or a host that handed over a string.
*
* Only one thing needs it today: SPRSAV resolving an image path that does
* not exist relative to the working directory, which is what makes a `.bas`
* beside its art work from anywhere. Group F's disk verbs will want the
* same, which is why it is on the runtime rather than in the sprite state.
*/
char sourcepath[AKBASIC_MAX_LINE_LENGTH];
/*
* The armed interrupts, and the environment the one currently running was
* entered through.
*
* `handlerenv` is doing two jobs at once. It is the "a handler is running"
* flag -- an interrupt does not interrupt an interrupt, which is what keeps a
* collision that persists across the handler from recursing until the
* environment pool is gone. And it is the identity RETURN compares against,
* so the flag clears on the RETURN that leaves *this* handler rather than on
* the first RETURN of any GOSUB the handler itself makes.
*/
akbasic_Interrupt interrupts[AKBASIC_MAX_INTERRUPTS];
akbasic_Environment *handlerenv;
/*
* The status code of the error being reported, for `ER#` to carry into a
* TRAP handler. Set by the reporting path, which is the only place that
* still has the error context; akbasic_runtime_error() sees a message and a
* class, both of which have already lost the number.
*/
int lasterrorstatus;
/*
* The host's clock, in milliseconds, as of its last akbasic_runtime_settime()
* call. The interpreter does not read a clock: it owns no loop and must not
@@ -164,6 +257,17 @@ typedef struct akbasic_Runtime
/* REPL line assembly */
char userline[AKBASIC_MAX_LINE_LENGTH];
/*
* Set by the scanner when the line it just read began with a line number.
*
* It is what tells the REPL apart from a program. A line typed *with* a
* number is program text and is filed; a line typed *without* one is a
* direct-mode statement and runs now. Without this the REPL could only run
* the handful of verbs marked immediate, so `PRINT 2 + 2` at the prompt was
* silently stored rather than answered -- see TODO.md section 5.
*/
bool hadlinenumber;
/* Scanner state */
char line[AKBASIC_MAX_LINE_LENGTH];
int current;
@@ -193,10 +297,33 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_init(akbasic_Runtime *obj, ak
* @param graphics Where DRAW, BOX, CIRCLE and PAINT land; may be NULL.
* @param audio Where SOUND, PLAY and VOL land; may be NULL.
* @param input Where GET and GETKEY read; may be NULL.
* @param sprites Where SPRITE, MOVSPR and SPRSAV land; may be NULL.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_devices(akbasic_Runtime *obj, akbasic_GraphicsBackend *graphics, akbasic_AudioBackend *audio, akbasic_InputBackend *input);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_devices(akbasic_Runtime *obj, akbasic_GraphicsBackend *graphics, akbasic_AudioBackend *audio, akbasic_InputBackend *input, akbasic_SpriteBackend *sprites);
/**
* @brief Tell the interpreter where the program it is running came from.
*
* An asset path a program writes -- `SPRSAV "ship.png", 1` -- is tried against
* the process working directory first and against this directory second, so a
* program stored beside its art works whether it was launched from its own
* directory or from somewhere else. Exactly what libakgl does for a sprite
* document naming its spritesheet.
*
* @p path is the program *file*, not its directory; the directory is taken from
* it. Passing NULL, or never calling this, leaves only the working directory,
* which is the right answer for a REPL session and for a host that handed the
* interpreter a string.
*
* @param obj Object to initialize, inspect, or modify.
* @param path Path to the program file, or NULL for none.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_BOUNDS When the path is longer than AKBASIC_MAX_LINE_LENGTH.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_source_path(akbasic_Runtime *obj, const char *path);
/**
* @brief Tell the interpreter what time the host thinks it is.
@@ -218,6 +345,153 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_devices(akbasic_Runtime *
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_settime(akbasic_Runtime *obj, int64_t timems);
/**
* @brief Move every line onto a new number, rewriting every reference to one.
*
* Moving lines is a permutation of `source[]`. Rewriting `GOTO`, `GOSUB`, `RUN`,
* `RESTORE`, `TRAP` and `COLLISION` targets to match is the work, and doing it on
* source text means telling a line number apart from digits inside a string
* literal -- `PRINT "GOTO 10"` must survive.
*
* 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. A `GOTO` to a *label* needs no
* rewriting at all, which is the strongest argument for writing programs that
* way.
*
* @param obj Object to initialize, inspect, or modify.
* @param newstart The number the first renumbered line takes.
* @param increment How far apart the new numbers are; must be positive.
* @param oldstart Renumber from this line on, leaving anything before it alone.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_VALUE When the increment is not positive, or a renumbered line would land on a kept one.
* @throws AKBASIC_ERR_BOUNDS When a new number would exceed AKBASIC_MAX_SOURCE_LINES, or a rewritten line would not fit.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_renumber(akbasic_Runtime *obj, int64_t newstart, int64_t increment, int64_t oldstart);
/**
* @brief File every `LABEL` in the stored program before any of it runs.
*
* Without this a label exists only from the moment its `LABEL` statement
* executes, so `GOTO` and `GOSUB` reach backwards and never forwards -- and an
* interrupt handler, which by definition sits on a line normal flow does not
* fall into, could not be named by label at all. That is the case this exists
* for; forward `GOTO` is the improvement that comes with it.
*
* The scan is textual rather than a parse: `LABEL` at the start of a statement,
* outside a string literal, followed by an identifier. Parsing every line up
* front would raise on lines the program would never have reached, which is a
* worse trade than a scanner that understands one keyword.
*
* `LABEL` still executes normally and still files itself, so a label re-filed at
* run time wins. A name that appears twice therefore resolves to the last one in
* the source until one of them runs.
*
* Called by akbasic_runtime_set_mode() on every entry into AKBASIC_MODE_RUN,
* which is the one point `RUN`, `CONT`, akbasic_runtime_start() and the end of a
* RUNSTREAM load all pass through. A host has no reason to call it directly.
*
* @param obj Object to initialize, inspect, or modify.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL or the runtime has no environment.
* @throws AKBASIC_ERR_BOUNDS When the program holds more labels than AKBASIC_MAX_LABELS.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_scan_labels(akbasic_Runtime *obj);
/**
* @brief Point an interrupt source at a handler, replacing whatever it had.
*
* Exactly one of @p line and @p label carries the target. A label is stored by
* name and resolved when the interrupt fires; see #akbasic_Interrupt for why.
*
* Arming does not clear a pending event. A program that disarms and re-arms
* around a critical section still sees the collision that happened inside it,
* which is the behaviour that loses no events.
*
* @param obj Object to initialize, inspect, or modify.
* @param source Which interrupt to arm.
* @param line Line number to enter, or 0 when @p label carries the target.
* @param label Label to enter, or NULL when @p line carries the target.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_BOUNDS When `source` is out of range or the label is too long.
* @throws AKBASIC_ERR_VALUE When neither or both of `line` and `label` name a target.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_arm_interrupt(akbasic_Runtime *obj, akbasic_InterruptSource source, int64_t line, const char *label);
/**
* @brief Stop an interrupt source from entering a handler.
*
* Any event already pending on it is dropped: a program that has said it no
* longer cares should not be sent into a handler it has just taken down.
*
* @param obj Object to initialize, inspect, or modify.
* @param source Which interrupt to disarm.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_BOUNDS When `source` is out of range.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_disarm_interrupt(akbasic_Runtime *obj, akbasic_InterruptSource source);
/**
* @brief Record that an interrupt source fired.
*
* Cheap and safe to call every frame from a device backend, whether or not
* anything is armed: an unarmed source records nothing, so a host does not have
* to ask what the script has subscribed to. The handler is entered later, by
* akbasic_runtime_step(), at a line boundary -- never from inside the code that
* raised it.
*
* @param obj Object to initialize, inspect, or modify.
* @param source Which interrupt fired.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_BOUNDS When `source` is out of range.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_raise_interrupt(akbasic_Runtime *obj, akbasic_InterruptSource source);
/**
* @brief Enter the handler of the first pending interrupt, if one is due.
*
* Called by akbasic_runtime_step() between source lines, which is the only place
* it is safe: a handler entered mid-statement would return into the middle of a
* line, and the parser holds no state that could resume there. That granularity
* is the same one block skipping already works at -- see TODO.md.
*
* Entering a handler is a GOSUB the program did not write: a scope is pushed,
* its return line is the line that was about to run, and the handler's RETURN
* pops back to it. So a handler must end in RETURN, exactly as on a C128.
*
* Sources are tried in enum order, and only one is entered per call. Nothing is
* entered while a handler is already running.
*
* @param obj Object to initialize, inspect, or modify.
* @param entered Output destination populated by the function; true when a handler was entered. May be NULL.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_UNDEFINED When the handler's label names nothing in the program.
* @throws AKBASIC_ERR_ENVIRONMENT When the environment pool is exhausted.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_service_interrupts(akbasic_Runtime *obj, bool *entered);
/**
* @brief Publish `ER#` and `EL#` into the global scope for a TRAP handler.
*
* A C128 spells these `ER` and `EL` as bare reserved names. This dialect has no
* bare variable names -- an identifier carries its type in a suffix, and a name
* without one is a label -- so they are ordinary global integers. See TODO.md
* section 5.
*
* @param obj Object to initialize, inspect, or modify.
* @param status The status code of the error.
* @param line The line it was reported on.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_BOUNDS When no variable slot is free.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_trap_set_error_variables(akbasic_Runtime *obj, int status, int64_t line);
/**
* @brief Reset the per-line state without disturbing the program or variables.
* @param obj Object to initialize, inspect, or modify.