Files
akbasic/src/runtime_disk.c

756 lines
28 KiB
C
Raw Normal View History

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>
2026-07-31 21:50:37 -04:00
/**
* @file runtime_disk.c
* @brief The group F verbs: files, channels, and the ones that need a 1541.
*
* Split three ways, and the split is the whole design.
*
* - **Verbs that mean something on a filesystem** are implemented against
* `aksl_f*`: DOPEN, DCLOSE, APPEND, RECORD, SCRATCH, RENAME, COPY, CONCAT,
* DIRECTORY and the binary pair BSAVE/BLOAD.
* - **Verbs that are spellings of ones already here** are aliases: SAVE and
* LOAD are DSAVE and DLOAD, CATALOG is DIRECTORY, DVERIFY is VERIFY.
* - **Verbs that need the hardware** are refused by name, with the reason:
* HEADER, COLLECT, BACKUP, BOOT and DCLEAR. Formatting a disk, validating
* one, duplicating one and resetting a drive have no filesystem meaning that
* is not a lie.
*
* Channel numbers are the program's, written after `#`. A leading `#` is
* optional everywhere -- `DOPEN #1, "F"` and `DOPEN 1, "F"` are the same
* statement -- because the scanner reads `#` as its own token and a verb name
* cannot carry one.
*/
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akbasic/args.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "verbs.h"
/* Most verbs answer "did something happen"; this is that answer. */
#define SUCCEED_TRUE(__obj, __dest) \
do { \
*(__dest) = &(__obj)->staticTrueValue; \
} while ( 0 )
akerr_ErrorContext *akbasic_disk_state_init(akbasic_DiskState *obj)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL disk state in init");
memset(obj, 0, sizeof(*obj));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_disk_close_all(akbasic_DiskState *obj)
{
PREPARE_ERROR(errctx);
int i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL disk state in close_all");
/*
* A loop, so CATCH and the _BREAK macros are banned in here. A close that
* fails is reported, but the rest are still closed: leaking the others
* because one of them was already gone would be a poor trade.
*/
for ( i = 0; i < AKBASIC_MAX_CHANNELS; i++ ) {
if ( obj->channels[i].fp != NULL ) {
IGNORE(aksl_fclose(obj->channels[i].fp));
obj->channels[i].fp = NULL;
obj->channels[i].name[0] = '\0';
obj->channels[i].writing = false;
}
}
SUCCEED_RETURN(errctx);
}
/** @brief Check a channel number and hand back its slot. */
static akerr_ErrorContext *channel_slot(akbasic_Runtime *obj, int64_t number, akbasic_Channel **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (number >= 0 && number < AKBASIC_MAX_CHANNELS),
AKBASIC_ERR_BOUNDS, "Channel %" PRId64 " is outside 0..%d",
number, AKBASIC_MAX_CHANNELS - 1);
*dest = &obj->disk_state.channels[number];
SUCCEED_RETURN(errctx);
}
/** @brief The slot for an already-open channel, or an error naming the number. */
static akerr_ErrorContext *open_channel(akbasic_Runtime *obj, int64_t number, akbasic_Channel **dest)
{
PREPARE_ERROR(errctx);
PASS(errctx, channel_slot(obj, number, dest));
FAIL_ZERO_RETURN(errctx, ((*dest)->fp != NULL), AKBASIC_ERR_STATE,
"Channel %" PRId64 " is not open", number);
SUCCEED_RETURN(errctx);
}
/** @brief Evaluate one argument as a string. */
static akerr_ErrorContext *string_arg(akbasic_Runtime *obj, akbasic_ASTLeaf *arg, const char *verb, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
akbasic_Value *value = NULL;
FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, "%s expected a file name", verb);
PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype != AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE,
"%s expected a file name", verb);
snprintf(dest, len, "%s", value->stringval);
SUCCEED_RETURN(errctx);
}
/** @brief Evaluate one argument as an integer. */
static akerr_ErrorContext *int_arg(akbasic_Runtime *obj, akbasic_ASTLeaf *arg, const char *verb, int64_t *dest)
{
PREPARE_ERROR(errctx);
akbasic_Value *value = NULL;
FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, "%s expected a number", verb);
PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE,
"%s expected a number", verb);
*dest = (value->valuetype == AKBASIC_TYPE_FLOAT)
? (int64_t)value->floatval : value->intval;
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------- DOPEN / APPEND -- */
/** @brief Shared by DOPEN and APPEND, which differ only in the mode. */
static akerr_ErrorContext *open_file(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, const char *mode)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
akbasic_Channel *channel = NULL;
char name[AKBASIC_MAX_STRING_LENGTH];
int64_t number = 0;
bool writing = (mode[0] != 'r');
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, int_arg(obj, arg, verb, &number));
PASS(errctx, channel_slot(obj, number, &channel));
FAIL_NONZERO_RETURN(errctx, (channel->fp != NULL), AKBASIC_ERR_STATE,
"Channel %" PRId64 " is already open on \"%s\"", number, channel->name);
PASS(errctx, string_arg(obj, (arg != NULL ? arg->next : NULL), verb, name, sizeof(name)));
/*
* DOPEN's third argument is the C128's `W` for write. It arrives as a bare
* identifier, which is a label to this dialect and so cannot be evaluated --
* so what is read is the *leaf*, not its value.
*/
if ( arg != NULL && arg->next != NULL && arg->next->next != NULL ) {
akbasic_ASTLeaf *modeleaf = arg->next->next;
if ( modeleaf->leaftype == AKBASIC_LEAF_IDENTIFIER &&
(modeleaf->identifier[0] == 'W' || modeleaf->identifier[0] == 'w') ) {
mode = "w";
writing = true;
}
}
PASS(errctx, aksl_fopen(name, mode, &channel->fp));
snprintf(channel->name, sizeof(channel->name), "%s", name);
channel->writing = writing;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_dopen(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in DOPEN");
PASS(errctx, open_file(obj, expr, "DOPEN", "r"));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_append(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in APPEND");
PASS(errctx, open_file(obj, expr, "APPEND", "a"));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_dclose(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
akbasic_Channel *channel = NULL;
int64_t number = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in DCLOSE");
arg = akbasic_leaf_first_argument(expr);
if ( arg == NULL ) {
/* Bare DCLOSE closes everything, which is what a C128 does. */
PASS(errctx, akbasic_disk_close_all(&obj->disk_state));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
PASS(errctx, int_arg(obj, arg, "DCLOSE", &number));
PASS(errctx, open_channel(obj, number, &channel));
PASS(errctx, aksl_fclose(channel->fp));
channel->fp = NULL;
channel->name[0] = '\0';
channel->writing = false;
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------------- RECORD -- */
akerr_ErrorContext *akbasic_cmd_record(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
akbasic_Channel *channel = NULL;
int64_t number = 0;
int64_t record = 0;
int64_t offset = 1;
int64_t size = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in RECORD");
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, int_arg(obj, arg, "RECORD", &number));
PASS(errctx, open_channel(obj, number, &channel));
PASS(errctx, int_arg(obj, (arg != NULL ? arg->next : NULL), "RECORD", &record));
if ( arg != NULL && arg->next != NULL && arg->next->next != NULL ) {
PASS(errctx, int_arg(obj, arg->next->next, "RECORD", &offset));
}
/*
* A C128's RECORD positions within a *relative* file, whose record length was
* fixed when the file was created. There is no such thing on a filesystem, so
* a record is taken to be a line: the file is rewound and read forward. That
* is slower than a seek and it is the only definition that does not require
* inventing a record length the file does not have. TODO.md section 5.
*/
FAIL_ZERO_RETURN(errctx, (record >= 1), AKBASIC_ERR_VALUE,
"RECORD numbers records from 1, not %" PRId64, record);
PASS(errctx, aksl_fseek(channel->fp, 0, SEEK_SET));
for ( size = 1; size < record; size++ ) {
char line[AKBASIC_MAX_STRING_LENGTH];
size_t length = 0;
akerr_ErrorContext *got = aksl_fgets(line, sizeof(line), channel->fp, &length);
if ( got != NULL ) {
got->handled = true;
IGNORE(akerr_release_error(got));
break;
}
}
if ( offset > 1 ) {
PASS(errctx, aksl_fseek(channel->fp, (long)(offset - 1), SEEK_CUR));
}
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------- file management -- */
akerr_ErrorContext *akbasic_cmd_scratch(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
char name[AKBASIC_MAX_STRING_LENGTH];
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in SCRATCH");
PASS(errctx, string_arg(obj, akbasic_leaf_first_argument(expr), "SCRATCH", name, sizeof(name)));
FAIL_ZERO_RETURN(errctx, (remove(name) == 0), AKERR_IO,
"SCRATCH could not delete \"%s\"", name);
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_rename(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
char from[AKBASIC_MAX_STRING_LENGTH];
char to[AKBASIC_MAX_STRING_LENGTH];
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in RENAME");
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, string_arg(obj, arg, "RENAME", from, sizeof(from)));
PASS(errctx, string_arg(obj, (arg != NULL ? arg->next : NULL), "RENAME", to, sizeof(to)));
FAIL_ZERO_RETURN(errctx, (rename(from, to) == 0), AKERR_IO,
"RENAME could not rename \"%s\" to \"%s\"", from, to);
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/** @brief Shared by COPY and CONCAT, which differ only in the destination mode. */
static akerr_ErrorContext *copy_file(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, const char *mode)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
FILE *in = NULL;
FILE *out = NULL;
char from[AKBASIC_MAX_STRING_LENGTH];
char to[AKBASIC_MAX_STRING_LENGTH];
char buffer[4096];
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, string_arg(obj, arg, verb, from, sizeof(from)));
PASS(errctx, string_arg(obj, (arg != NULL ? arg->next : NULL), verb, to, sizeof(to)));
PASS(errctx, aksl_fopen(from, "rb", &in));
ATTEMPT {
CATCH(errctx, aksl_fopen(to, mode, &out));
for ( ;; ) {
size_t got = 0;
size_t put = 0;
bool done = false;
akerr_ErrorContext *read = aksl_fread(buffer, 1, sizeof(buffer), in, &got);
if ( read != NULL ) {
/*
* A short read is how a file *ends*: aksl_fread reports fewer
* items than asked for as an error, and `got` still holds what
* it managed. Writing that out before stopping is the whole
* copy for any file smaller than the buffer -- the first
* version broke here and produced an empty destination every
* time.
*/
read->handled = true;
IGNORE(akerr_release_error(read));
done = true;
}
if ( got > 0 ) {
/*
* PASS rather than CATCH: this is a loop, and CATCH expands to
* a break that would leave the rest of the ATTEMPT running with
* an error pending.
*/
PASS(errctx, aksl_fwrite(buffer, 1, got, out, &put));
}
if ( done || got == 0 ) {
break;
}
}
} CLEANUP {
IGNORE(aksl_fclose(in));
if ( out != NULL ) {
IGNORE(aksl_fclose(out));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_copy(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in COPY");
PASS(errctx, copy_file(obj, expr, "COPY", "wb"));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_concat(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in CONCAT");
/* Appends rather than replaces, which is the whole difference from COPY. */
PASS(errctx, copy_file(obj, expr, "CONCAT", "ab"));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------------- DIRECTORY -- */
akerr_ErrorContext *akbasic_cmd_directory(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)expr; (void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in DIRECTORY");
/*
* Refused rather than half-built. Listing a directory needs opendir/readdir,
* which `libakstdlib` does not wrap -- and this project's rule is that a
* missing capability gets filed upstream rather than worked around here
Split the documentation by who reads it README.md was 577 lines and answered four different questions at once: what the project is, how to build it, every verb and function in the language, and how to maintain the test harness. The verb and function lists had already been written a second time in docs/11 and docs/12, which is how a list of that size goes stale -- there is no way to notice the two have drifted apart. README.md is now 150 lines and holds only what somebody evaluating the project needs: what it is, the quickstart, why it was rewritten in C, the five rules embedding imposes on the design, the two ways to use it, and where everything else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md. The akbasic_TextSink struct moved to docs/10-embedding.md rather than being deleted. It was the corpus's only `c excerpt=` block -- the check that caught the stale struct two commits ago -- so dropping it with the README would have quietly retired a test. docs/10 also stopped claiming README.md carries the full API surface and the pool limits, which the trim made false. CLAUDE.md went from 458 lines to 62, because almost none of it was agent-specific. The project goals, the Go reference and its architecture, the dependency version and ABI rules, the four ways an embedded build collides, the libakerror convention, the error-code range map and the style rules are all things a maintainer needs, and they are now in MAINTENANCE.md with one copy to keep true. CLAUDE.md points there and keeps only the rules no test enforces: tests in the same commit asserting the correct contract, file a missing dependency capability upstream, do not edit generated output or tests/reference/, co-author your commits. Four claims did not survive the move, having gone stale where nothing could notice: - "The repository is currently empty apart from its submodules -- no commits, no source tree, no build files." There are 43 commits. - libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE) at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is gone; only a historical mention in a comment remains. - "akbasic_init() claims 512-767." There is no akbasic_init. It is akbasic_error_register(), called from akbasic_runtime_init(). - Time-relative phrasing ("libakgl hit two of them in the last week"). Five places pointed at CLAUDE.md for the range map or the file-it-upstream rule and now point at MAINTENANCE.md: include/akbasic/error.h, src/runtime_disk.c and three entries in TODO.md. Both source changes are comments. deps/libakgl/TODO.md cites it too and is left alone; it is a submodule, and the rule it quotes is still reachable from CLAUDE.md. ctest is green at 95 of 95, docs_examples included: 36 programs, 9 transcripts, 44 output comparisons, 3 C snippets, 1 excerpt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
* (MAINTENANCE.md). Filed in deps/libakstdlib/TODO.md.
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>
2026-07-31 21:50:37 -04:00
*
* The alternative was shelling out to `ls`, which a library has no business
* doing, or calling readdir directly and stepping outside the error
* convention every other call in this file follows.
*/
FAIL_RETURN(errctx, AKBASIC_ERR_DEVICE,
"DIRECTORY is not implemented: libakstdlib has no directory-reading wrapper yet");
}
/* ------------------------------------------------------------ BSAVE/BLOAD -- */
akerr_ErrorContext *akbasic_cmd_bsave(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
FILE *fp = NULL;
char name[AKBASIC_MAX_STRING_LENGTH];
int64_t from = 0;
int64_t to = 0;
size_t put = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in BSAVE");
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, string_arg(obj, arg, "BSAVE", name, sizeof(name)));
PASS(errctx, int_arg(obj, (arg != NULL ? arg->next : NULL), "BSAVE", &from));
PASS(errctx, int_arg(obj, (arg != NULL && arg->next != NULL ? arg->next->next : NULL),
"BSAVE", &to));
FAIL_ZERO_RETURN(errctx, (from != 0 && to > from), AKBASIC_ERR_VALUE,
"BSAVE needs a start address and a higher end address");
PASS(errctx, aksl_fopen(name, "wb", &fp));
ATTEMPT {
CATCH(errctx, aksl_fwrite((const void *)(uintptr_t)from, 1, (size_t)(to - from), fp, &put));
} CLEANUP {
IGNORE(aksl_fclose(fp));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_bload(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
FILE *fp = NULL;
char name[AKBASIC_MAX_STRING_LENGTH];
int64_t at = 0;
int64_t limit = 0;
size_t got = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in BLOAD");
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, string_arg(obj, arg, "BLOAD", name, sizeof(name)));
PASS(errctx, int_arg(obj, (arg != NULL ? arg->next : NULL), "BLOAD", &at));
/*
* The length is required, unlike a C128's BLOAD which reads until the file
* ends. A file longer than the caller expected would otherwise write past
* whatever it was pointed at, and there is no way to notice: a BASIC integer
* here is a real address. Refusing to guess is the only safe answer -- and
* checked before it is read, so a missing one says *that* rather than
* "expected a number".
*/
FAIL_ZERO_RETURN(errctx,
(arg != NULL && arg->next != NULL && arg->next->next != NULL),
AKBASIC_ERR_SYNTAX,
"BLOAD needs an address and a maximum length");
PASS(errctx, int_arg(obj, arg->next->next, "BLOAD", &limit));
FAIL_ZERO_RETURN(errctx, (at != 0 && limit > 0), AKBASIC_ERR_VALUE,
"BLOAD needs an address and a maximum length");
PASS(errctx, aksl_fopen(name, "rb", &fp));
ATTEMPT {
akerr_ErrorContext *read = aksl_fread((void *)(uintptr_t)at, 1, (size_t)limit, fp, &got);
if ( read != NULL ) {
/* A short read is the ordinary case: the file was smaller than the cap. */
read->handled = true;
IGNORE(akerr_release_error(read));
}
} CLEANUP {
IGNORE(aksl_fclose(fp));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------ channel I/O -- */
akerr_ErrorContext *akbasic_disk_write(akbasic_Runtime *obj, int64_t number, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_Channel *channel = NULL;
size_t put = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && text != NULL), AKERR_NULLPOINTER,
"NULL argument in disk_write");
PASS(errctx, open_channel(obj, number, &channel));
FAIL_ZERO_RETURN(errctx, channel->writing, AKBASIC_ERR_STATE,
"Channel %" PRId64 " was opened for reading", number);
PASS(errctx, aksl_fwrite(text, 1, strlen(text), channel->fp, &put));
PASS(errctx, aksl_fwrite("\n", 1, 1, channel->fp, &put));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_disk_readline(akbasic_Runtime *obj, int64_t number, char *dest, size_t len, bool *eof)
{
PREPARE_ERROR(errctx);
akbasic_Channel *channel = NULL;
akerr_ErrorContext *got = NULL;
char *newline = NULL;
size_t length = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL && eof != NULL), AKERR_NULLPOINTER,
"NULL argument in disk_readline");
PASS(errctx, open_channel(obj, number, &channel));
FAIL_NONZERO_RETURN(errctx, channel->writing, AKBASIC_ERR_STATE,
"Channel %" PRId64 " was opened for writing", number);
*eof = false;
dest[0] = '\0';
got = aksl_fgets(dest, len, channel->fp, &length);
if ( got != NULL ) {
/*
* End of file is not an error here, the same way it is not one for the
* sink's readline: running out of input is a state a program handles.
*/
got->handled = true;
IGNORE(akerr_release_error(got));
*eof = true;
SUCCEED_RETURN(errctx);
}
newline = strchr(dest, '\n');
if ( newline != NULL ) {
*newline = '\0';
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_print_channel(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
akbasic_Value *value = NULL;
char text[AKBASIC_MAX_STRING_LENGTH];
int64_t number = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in PRINT#");
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, int_arg(obj, arg, "PRINT#", &number));
FAIL_ZERO_RETURN(errctx, (arg != NULL && arg->next != NULL), AKBASIC_ERR_SYNTAX,
"Expected PRINT #(channel), (expression)");
PASS(errctx, akbasic_runtime_evaluate(obj, arg->next, &value));
PASS(errctx, akbasic_value_to_string(value, text, sizeof(text)));
PASS(errctx, akbasic_disk_write(obj, number, text));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_input_channel(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
akbasic_ASTLeaf *identifier = NULL;
akbasic_ASTLeaf literal;
akbasic_ASTLeaf assign;
akbasic_Value *unused = NULL;
char text[AKBASIC_MAX_STRING_LENGTH];
int64_t number = 0;
bool eof = false;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in INPUT#");
arg = akbasic_leaf_first_argument(expr);
PASS(errctx, int_arg(obj, arg, "INPUT#", &number));
identifier = (arg != NULL ? arg->next : NULL);
FAIL_ZERO_RETURN(errctx, akbasic_leaf_is_identifier(identifier), AKBASIC_ERR_SYNTAX,
"Expected INPUT #(channel), (variable)");
PASS(errctx, akbasic_disk_readline(obj, number, text, sizeof(text), &eof));
/*
* End of file leaves the variable empty rather than raising. A program reads
* until it gets nothing back, which is what it can check -- and a numeric
* variable reading an empty line gets zero, the same as a C128.
*/
PASS(errctx, akbasic_leaf_init(&literal, AKBASIC_LEAF_LITERAL_STRING));
snprintf(literal.literal_string, sizeof(literal.literal_string), "%s", text);
if ( akbasic_leaf_identifier_type(identifier) == AKBASIC_TYPE_INTEGER ) {
long long converted = 0;
literal.leaftype = AKBASIC_LEAF_LITERAL_INT;
literal.literal_int = 0;
if ( !eof && text[0] != '\0' ) {
PASS(errctx, aksl_atoll(text, &converted));
literal.literal_int = (int64_t)converted;
}
} else if ( akbasic_leaf_identifier_type(identifier) == AKBASIC_TYPE_FLOAT ) {
literal.leaftype = AKBASIC_LEAF_LITERAL_FLOAT;
literal.literal_float = 0.0;
if ( !eof && text[0] != '\0' ) {
PASS(errctx, aksl_atof(text, &literal.literal_float));
}
}
PASS(errctx, akbasic_leaf_init(&assign, AKBASIC_LEAF_BINARY));
assign.left = identifier;
assign.right = &literal;
assign.operator_ = AKBASIC_TOK_ASSIGNMENT;
PASS(errctx, akbasic_runtime_evaluate(obj, &assign, &unused));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- VERIFY -- */
akerr_ErrorContext *akbasic_cmd_dverify(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
FILE *fp = NULL;
char name[AKBASIC_MAX_STRING_LENGTH];
char line[AKBASIC_MAX_LINE_LENGTH];
int64_t lineno = 0;
int64_t mismatch = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in VERIFY");
PASS(errctx, string_arg(obj, akbasic_leaf_first_argument(expr), "VERIFY", name, sizeof(name)));
/*
* A C128 verifies the program in memory against the one on disk byte for
* byte, because a 1541 write could fail silently. Here the comparison is
* line by line against the file's text, which is the same question asked of
* a filesystem: is what I would save what is already there?
*/
PASS(errctx, aksl_fopen(name, "r", &fp));
ATTEMPT {
for ( lineno = 0; lineno < AKBASIC_MAX_SOURCE_LINES; lineno++ ) {
size_t length = 0;
akerr_ErrorContext *got = NULL;
char *newline = NULL;
if ( obj->source[lineno].code[0] == '\0' ) {
continue;
}
got = aksl_fgets(line, sizeof(line), fp, &length);
if ( got != NULL ) {
got->handled = true;
IGNORE(akerr_release_error(got));
mismatch += 1;
break;
}
newline = strchr(line, '\n');
if ( newline != NULL ) {
*newline = '\0';
}
/*
* The stored line has had its number stripped, so the file's copy is
* compared from past its own number. PASS rather than CATCH: this is
* a loop.
*/
{
const char *filetext = line;
while ( *filetext == ' ' ) {
filetext += 1;
}
while ( *filetext >= '0' && *filetext <= '9' ) {
filetext += 1;
}
while ( *filetext == ' ' ) {
filetext += 1;
}
if ( strcmp(filetext, obj->source[lineno].code) != 0 ) {
mismatch += 1;
}
}
}
} CLEANUP {
IGNORE(aksl_fclose(fp));
} PROCESS(errctx) {
} FINISH(errctx, true);
FAIL_NONZERO_RETURN(errctx, (mismatch != 0), AKBASIC_ERR_VALUE,
"VERIFY: \"%s\" does not match the program in memory (%" PRId64 " lines differ)",
name, mismatch);
PASS(errctx, akbasic_runtime_println(obj, "OK"));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------- needs a real 1541 --- */
/**
* @brief Refuse a verb that has no meaning without the drive, and say why.
*
* Five of them, and the reasoning is the same each time: formatting a disk,
* validating one, duplicating one, resetting a drive and booting from one are
* operations on a physical device. A filesystem has no equivalent that is not a
* lie -- `HEADER` would have to mean "delete everything in this directory",
* which is a spectacularly bad thing to do to somebody who typed a C128 verb.
*/
static akerr_ErrorContext *no_drive(const char *verb, const char *what)
{
PREPARE_ERROR(errctx);
FAIL_RETURN(errctx, AKBASIC_ERR_DEVICE,
"%s %s, and there is no disk drive here -- only a filesystem", verb, what);
}
akerr_ErrorContext *akbasic_cmd_header(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)obj; (void)expr; (void)lval; (void)rval; (void)dest;
PASS(errctx, no_drive("HEADER", "formats a disk"));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_collect(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)obj; (void)expr; (void)lval; (void)rval; (void)dest;
PASS(errctx, no_drive("COLLECT", "validates a disk's block allocation map"));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_backup(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)obj; (void)expr; (void)lval; (void)rval; (void)dest;
PASS(errctx, no_drive("BACKUP", "duplicates one disk onto another"));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_boot(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)obj; (void)expr; (void)lval; (void)rval; (void)dest;
PASS(errctx, no_drive("BOOT", "loads and runs a boot sector"));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_dclear(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
(void)expr; (void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in DCLEAR");
/*
* The one of the five with a defensible meaning. DCLEAR resets the drive and
* closes every channel it had open; the second half is exactly
* akbasic_disk_close_all(), so that is what it does. The first half has
* nothing to reset.
*/
PASS(errctx, akbasic_disk_close_all(&obj->disk_state));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}