Files
akbasic/src/parser_commands.c

1130 lines
45 KiB
C
Raw Normal View History

Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
/**
* @file parser_commands.c
* @brief Verbs that need their own parse path rather than a plain expression.
*
* Ported from basicparser_commands.go. Two of these mutate runtime state from
* inside the parser and that is not an accident: DEF installs the function so a
* later line can call it, and FOR builds and installs the loop's environment
* before the body is ever scanned. The waitingForCommand scheme depends on the
* latter.
*/
#include <inttypes.h>
#include <string.h>
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
#include <strings.h>
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
#include <akerror.h>
#include <akbasic/error.h>
#include <akbasic/parser.h>
#include <akbasic/runtime.h>
#include "verbs.h"
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
akerr_ErrorContext *akbasic_parse_arglist(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_Token *operator_ = NULL;
/*
* The generic "this verb takes a comma-separated list" path, shared by the
* graphics and sound verbs. The default command path parses a *single*
* expression, which quietly turns COLOR 0, 1 into COLOR 0 with a stray 1 --
* so every verb taking more than one argument needs this rather than NULL.
*
* The verb name comes back out of the token stream rather than being passed
* in, because a parse handler is dispatched by name and the table has no
* column to carry one.
*/
PASS(errctx, akbasic_parser_previous(parser, &operator_));
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
/*
* Checked before parsing rather than after. Handing an empty token stream to
* the expression parser fails deep inside it with "peek() returned nil
* token!", which tells a program author nothing at all -- and this path is
* reached by every verb that takes a list, so the bad message was the one
* most people saw.
*/
FAIL_ZERO_RETURN(errctx, (akbasic_parser_peek(parser) != NULL), AKBASIC_ERR_SYNTAX,
"%s expected at least one argument", operator_->lexeme);
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL), AKBASIC_ERR_SYNTAX,
"%s expected at least one argument", operator_->lexeme);
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, operator_->lexeme, arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
Accept GRAPHIC CLR and a negative DATA item Two parse handlers refusing something the documentation promises. Landing together because they are the same defect twice -- a verb's own argument shape falling through to a general path that cannot see it -- in one file, found by one program, and verified in one pass. **`GRAPHIC CLR`** is given as `GRAPHIC mode | CLR` in both docs/06-graphics.md and docs/11-verb-reference.md, and was refused: `CLR` is a verb of its own, so the generic arglist path scanned it as a command token and the expression parser answered "Expected expression or literal". `akbasic_parse_graphic()` takes it as this verb's keyword argument and emits mode 5 -- which `akbasic_cmd_graphic()` already treats as "drop the saved shapes and go back to text", so both spellings are one statement and the exec handler is untouched. The documentation was right all along; nothing in it changes. **`DATA -5`** was refused by `akbasic_parse_data()`, and only there: `READ` scans the source text directly (src/data.c) and always returned the -5 intact. So the value was right and *reaching* the statement raised -- which, since section 4 settled that `DATA` at run time is a no-op, is what a program does with every `DATA` line it walks past. A table of coordinates or velocities is full of negative numbers, which is how a game found it. The fix accepts a unary minus over a numeric literal and nothing else: `DATA -A#` is still a mistake worth naming, and `akbasic_leaf_is_literal()` keeps meaning what it says because other callers rely on it. tests/read_data.c covers both mechanisms -- reading a negative item and reaching the line after it -- with a mixed-sign table and a negative float, since the two were never the same code path. TODO.md section 9 items 7 and 8, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:18:53 -04:00
/*
* GRAPHIC mode [, clear]
* GRAPHIC CLR
*
* `CLR` is a keyword argument rather than an expression, and the generic arglist
* path cannot see it as one: `CLR` is a verb of its own, so it scans as a
* command token and the expression parser refuses with "Expected expression or
* literal". Both `docs/06-graphics.md` and `docs/11-verb-reference.md` have given
* the form as `GRAPHIC mode | CLR` all along, so the documentation was right and
* the parser was not. TODO.md section 9 item 7.
*
* It becomes mode 5, which is what akbasic_cmd_graphic() already treats as "drop
* the saved shapes and go back to text" -- so `GRAPHIC CLR` and `GRAPHIC 5` are
* the same statement, and the exec handler needs no change.
*/
akerr_ErrorContext *akbasic_parse_graphic(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_Token *peeked = NULL;
peeked = akbasic_parser_peek(parser);
if ( peeked != NULL && peeked->tokentype == AKBASIC_TOK_COMMAND &&
strcmp(peeked->lexeme, "CLR") == 0 ) {
(void)akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND);
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist->right));
PASS(errctx, akbasic_leaf_new_literal_int(arglist->right, "5"));
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "GRAPHIC", arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
PASS(errctx, akbasic_parse_arglist(parser, dest));
SUCCEED_RETURN(errctx);
}
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
akerr_ErrorContext *akbasic_parse_draw(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *tail = NULL;
akbasic_Token *peeked = NULL;
akbasic_Token *operator_ = NULL;
/*
* DRAW source, x1,y1 TO x2,y2 TO x3,y3 -- a polyline, with TO between pairs
* instead of a comma. TO is a reserved word the argument list will not cross,
* so the pairs after the first are appended by hand and the whole thing
* flattens to one argument chain: source, x1, y1, x2, y2, ... The exec
* handler walks it in pairs and never has to know a TO was involved.
*/
PASS(errctx, akbasic_parser_previous(parser, &operator_));
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL && arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"DRAW expected a color source and a coordinate");
tail = arglist->right;
while ( tail->next != NULL ) {
tail = tail->next;
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
}
for ( ;; ) {
peeked = akbasic_parser_peek(parser);
if ( peeked == NULL ||
peeked->tokentype != AKBASIC_TOK_COMMAND ||
strcmp(peeked->lexeme, "TO") != 0 ) {
break;
}
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
AKBASIC_ERR_SYNTAX, "DRAW expected TO");
PASS(errctx, akbasic_parser_expression(parser, &tail->next));
FAIL_ZERO_RETURN(errctx, (tail->next != NULL), AKBASIC_ERR_SYNTAX,
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
"DRAW expected X after TO");
tail = tail->next;
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMA),
AKBASIC_ERR_SYNTAX, "DRAW expected TO X,Y");
PASS(errctx, akbasic_parser_expression(parser, &tail->next));
FAIL_ZERO_RETURN(errctx, (tail->next != NULL), AKBASIC_ERR_SYNTAX,
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
"DRAW expected Y after TO X,");
tail = tail->next;
Draw: implement the BASIC 7.0 graphics verbs GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
}
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, operator_->lexeme, arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
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
akerr_ErrorContext *akbasic_parse_movspr(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *form = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *tail = NULL;
akbasic_Token *operator_ = NULL;
akbasic_Token *peeked = NULL;
const char *formlexeme = "0";
/*
* MOVSPR is the one verb in the language whose arguments are not simply
* comma-separated. It has four forms and the separators are what tell them
* apart:
*
* MOVSPR n, x, y absolute
* MOVSPR n, +x, -y relative -- a signed coordinate, not a negative one
* MOVSPR n, d ; a polar: distance, then angle
* MOVSPR n, a # s continuous: angle, then speed
*
* So the form has to be decided here, where the tokens still exist, and
* carried to the exec handler somehow. It is carried as a synthetic integer
* literal prepended to the argument list, which flattens the whole statement
* to `form, n, a, b` -- the same trick akbasic_parse_draw uses to make a
* polyline look like a plain argument chain.
*/
PASS(errctx, akbasic_parser_previous(parser, &operator_));
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
PASS(errctx, akbasic_parser_new_leaf(parser, &form));
PASS(errctx, akbasic_parser_expression(parser, &arglist->right));
FAIL_ZERO_RETURN(errctx, (arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"MOVSPR expected a sprite number");
tail = arglist->right;
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMA),
AKBASIC_ERR_SYNTAX, "MOVSPR expected a comma after the sprite number");
/*
* A leading sign makes it relative. `+` has to be consumed here because the
* expression parser has no unary plus; `-` is left where it is, because
* unary minus is a real operator and re-parsing it would drop the sign.
*/
peeked = akbasic_parser_peek(parser);
if ( peeked != NULL &&
(peeked->tokentype == AKBASIC_TOK_PLUS || peeked->tokentype == AKBASIC_TOK_MINUS) ) {
formlexeme = "1";
if ( peeked->tokentype == AKBASIC_TOK_PLUS ) {
(void)akbasic_parser_match1(parser, AKBASIC_TOK_PLUS);
}
}
PASS(errctx, akbasic_parser_expression(parser, &tail->next));
FAIL_ZERO_RETURN(errctx, (tail->next != NULL), AKBASIC_ERR_SYNTAX,
"MOVSPR expected a coordinate, a distance or an angle");
tail = tail->next;
if ( akbasic_parser_match1(parser, AKBASIC_TOK_SEMICOLON) ) {
formlexeme = "2";
} else if ( akbasic_parser_match1(parser, AKBASIC_TOK_HASHMARK) ) {
formlexeme = "3";
} else {
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMA),
AKBASIC_ERR_SYNTAX,
"MOVSPR expected `, y', `; angle' or `# speed'");
if ( formlexeme[0] == '0' ) {
peeked = akbasic_parser_peek(parser);
if ( peeked != NULL && peeked->tokentype == AKBASIC_TOK_PLUS ) {
formlexeme = "1";
(void)akbasic_parser_match1(parser, AKBASIC_TOK_PLUS);
} else if ( peeked != NULL && peeked->tokentype == AKBASIC_TOK_MINUS ) {
formlexeme = "1";
}
} else if ( akbasic_parser_peek(parser) != NULL &&
akbasic_parser_peek(parser)->tokentype == AKBASIC_TOK_PLUS ) {
(void)akbasic_parser_match1(parser, AKBASIC_TOK_PLUS);
}
}
PASS(errctx, akbasic_parser_expression(parser, &tail->next));
FAIL_ZERO_RETURN(errctx, (tail->next != NULL), AKBASIC_ERR_SYNTAX,
"MOVSPR expected a second value");
PASS(errctx, akbasic_leaf_new_literal_int(form, formlexeme));
form->next = arglist->right;
arglist->right = form;
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, operator_->lexeme, arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
/**
* @brief Read an optional `WHILE`/`UNTIL` condition off a DO or a LOOP.
*
* Both ends of a DO/LOOP take the same optional clause, so both parse it the
* same way. @p kind comes back as one of the AKBASIC_LOOPCOND_* values and
* @p condition is NULL when there was no clause.
*/
static akerr_ErrorContext *parse_loop_condition(akbasic_Parser *parser, akbasic_ASTLeaf **condition, int *kind)
{
PREPARE_ERROR(errctx);
akbasic_Token *peeked = NULL;
*condition = NULL;
*kind = AKBASIC_LOOPCOND_NONE;
peeked = akbasic_parser_peek(parser);
if ( peeked == NULL || peeked->tokentype != AKBASIC_TOK_COMMAND ) {
SUCCEED_RETURN(errctx);
}
if ( strcmp(peeked->lexeme, "WHILE") == 0 ) {
*kind = AKBASIC_LOOPCOND_WHILE;
} else if ( strcmp(peeked->lexeme, "UNTIL") == 0 ) {
*kind = AKBASIC_LOOPCOND_UNTIL;
} else {
SUCCEED_RETURN(errctx);
}
(void)akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND);
/* A loop condition is a condition, so a lone `=` in it is an equality test. */
parser->comparing = true;
PASS(errctx, akbasic_parser_expression(parser, condition));
parser->comparing = false;
FAIL_ZERO_RETURN(errctx, (*condition != NULL), AKBASIC_ERR_SYNTAX,
"Expected a condition after WHILE or UNTIL");
SUCCEED_RETURN(errctx);
}
/*
* DO [WHILE|UNTIL (condition)]
*
* Pushes the loop's scope at parse time, exactly as FOR does and for the same
* reason: the condition leaf has to outlive the line it was written on, and an
* environment's leaf pool is what gives it somewhere to live.
*/
akerr_ErrorContext *akbasic_parse_do(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_Runtime *runtime = parser->runtime;
akbasic_Environment *parent = runtime->environment;
akbasic_Environment *newenv = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *condition = NULL;
int kind = AKBASIC_LOOPCOND_NONE;
int64_t firstline = parent->lineno + 1;
PASS(errctx, akbasic_runtime_new_environment(runtime));
newenv = runtime->environment;
runtime->environment = parent;
/*
* Parsed against the parent's token stream but stored in the new scope --
* the body is scanned line by line afterwards, and the new scope must not be
* active until parsing is done.
*/
PASS(errctx, parse_loop_condition(parser, &condition, &kind));
newenv->isDoLoop = true;
newenv->loopFirstLine = firstline;
newenv->doConditionKind = kind;
if ( condition != NULL ) {
PASS(errctx, akbasic_leaf_clone(condition, &newenv->doLeafPool, &newenv->doConditionLeaf));
}
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "DO", NULL));
runtime->environment = newenv;
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* LOOP [WHILE|UNTIL (condition)]
*
* The condition hangs off the command leaf's `.right`, with the WHILE/UNTIL
* choice in that leaf's integer literal and the expression on its `.left`. No
* cloning: this line is still scanned when the verb runs.
*/
akerr_ErrorContext *akbasic_parse_loop(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *holder = NULL;
akbasic_ASTLeaf *condition = NULL;
int kind = AKBASIC_LOOPCOND_NONE;
PASS(errctx, parse_loop_condition(parser, &condition, &kind));
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
if ( condition != NULL ) {
PASS(errctx, akbasic_parser_new_leaf(parser, &holder));
PASS(errctx, akbasic_leaf_init(holder, AKBASIC_LEAF_LITERAL_INT));
holder->literal_int = kind;
holder->left = condition;
}
PASS(errctx, akbasic_leaf_new_command(expr, "LOOP", holder));
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* ON (expression) GOTO|GOSUB (target) [, ...]
*
* Flattened to one argument chain: a marker carrying the GOSUB flag, the
* selector expression, then the targets. Same trick akbasic_parse_draw and
* akbasic_parse_movspr use, so the exec handler walks a plain list.
*/
akerr_ErrorContext *akbasic_parse_on(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *marker = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *tail = NULL;
akbasic_Token *operator_ = NULL;
bool gosub = false;
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
PASS(errctx, akbasic_parser_new_leaf(parser, &marker));
PASS(errctx, akbasic_leaf_init(marker, AKBASIC_LEAF_LITERAL_INT));
PASS(errctx, akbasic_parser_expression(parser, &marker->next));
FAIL_ZERO_RETURN(errctx, (marker->next != NULL), AKBASIC_ERR_SYNTAX,
"Expected ON (expression) GOTO|GOSUB (line) [, ...]");
tail = marker->next;
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
AKBASIC_ERR_SYNTAX, "Expected GOTO or GOSUB after ON (expression)");
PASS(errctx, akbasic_parser_previous(parser, &operator_));
if ( strcmp(operator_->lexeme, "GOSUB") == 0 ) {
gosub = true;
} else {
FAIL_NONZERO_RETURN(errctx, strcmp(operator_->lexeme, "GOTO"), AKBASIC_ERR_SYNTAX,
"Expected GOTO or GOSUB after ON (expression)");
}
marker->literal_int = (gosub ? 1 : 0);
for ( ;; ) {
PASS(errctx, akbasic_parser_expression(parser, &tail->next));
FAIL_ZERO_RETURN(errctx, (tail->next != NULL), AKBASIC_ERR_SYNTAX,
"Expected a line number or label after ON ... %s",
(gosub ? "GOSUB" : "GOTO"));
tail = tail->next;
if ( !akbasic_parser_match1(parser, AKBASIC_TOK_COMMA) ) {
break;
}
}
arglist->right = marker;
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "ON", arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* RESUME [NEXT | (line)]
*
* `NEXT` is a COMMAND token, so the default command path -- which parses the
* rval as an expression -- cannot read it: `RESUME NEXT` came back as "Expected
* expression or literal". The word is kept as the command leaf's rval so the
* exec handler can tell the three forms apart by looking at it.
*/
akerr_ErrorContext *akbasic_parse_resume(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *target = NULL;
akbasic_Token *peeked = NULL;
peeked = akbasic_parser_peek(parser);
if ( peeked != NULL && peeked->tokentype == AKBASIC_TOK_COMMAND &&
strcmp(peeked->lexeme, "NEXT") == 0 ) {
(void)akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND);
PASS(errctx, akbasic_parser_new_leaf(parser, &target));
PASS(errctx, akbasic_leaf_new_command(target, "NEXT", NULL));
} else if ( peeked != NULL && peeked->tokentype != AKBASIC_TOK_COLON ) {
PASS(errctx, akbasic_parser_expression(parser, &target));
}
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "RESUME", target));
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* PRINT [USING (format);] (expression)
*
* Only the USING form needs a parse handler; without it PRINT keeps the default
* path, which parses one expression. `USING` is a COMMAND token, so the default
* path could not read it -- the same reason RESUME needed one for `NEXT`.
*
* The result is a command leaf whose rval is an argument list of exactly two:
* the format and the value. A plain PRINT keeps its single-expression rval, so
* the exec handler tells them apart by looking for the list.
*/
akerr_ErrorContext *akbasic_parse_print(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *right = NULL;
akbasic_Token *peeked = NULL;
peeked = akbasic_parser_peek(parser);
/*
* `PRINT #1, expr` writes to a file channel. A C128 spells it `PRINT#1` with
* no space, which this scanner cannot produce: `PRINT#` reads as an
* identifier with an integer suffix, and a verb name carrying a suffix is
* refused (deviation 30). So the `#` is its own token and the space before
* it is required. Recorded in TODO.md section 5.
*
* The channel form is marked by a leaf named "PRINT#" holding an argument
* list of two: the channel and the value.
*/
if ( peeked != NULL && peeked->tokentype == AKBASIC_TOK_HASHMARK ) {
akbasic_ASTLeaf *arglist = NULL;
(void)akbasic_parser_match1(parser, AKBASIC_TOK_HASHMARK);
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
PASS(errctx, akbasic_parser_expression(parser, &arglist->right));
FAIL_ZERO_RETURN(errctx, (arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"Expected PRINT #(channel), (expression)");
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMA),
AKBASIC_ERR_SYNTAX,
"Expected a comma after PRINT #(channel)");
PASS(errctx, akbasic_parser_expression(parser, &arglist->right->next));
FAIL_ZERO_RETURN(errctx, (arglist->right->next != NULL), AKBASIC_ERR_SYNTAX,
"Expected PRINT #(channel), (expression)");
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "PRINT#", arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
if ( peeked == NULL || peeked->tokentype != AKBASIC_TOK_COMMAND ||
strcmp(peeked->lexeme, "USING") != 0 ) {
/* The ordinary PRINT: one expression, or none at all. */
if ( peeked != NULL && peeked->tokentype != AKBASIC_TOK_COLON ) {
PASS(errctx, akbasic_parser_expression(parser, &right));
}
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "PRINT", right));
*dest = expr;
SUCCEED_RETURN(errctx);
}
(void)akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND);
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
PASS(errctx, akbasic_parser_expression(parser, &arglist->right));
FAIL_ZERO_RETURN(errctx, (arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"Expected PRINT USING (format); (expression)");
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_SEMICOLON),
AKBASIC_ERR_SYNTAX,
"Expected a semicolon between PRINT USING's format and its value");
PASS(errctx, akbasic_parser_expression(parser, &arglist->right->next));
FAIL_ZERO_RETURN(errctx, (arglist->right->next != NULL), AKBASIC_ERR_SYNTAX,
"Expected PRINT USING (format); (expression)");
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "PRINT", arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* The argument list, for verbs that may be written with no arguments at all --
* bare `KEY` lists the macros, bare `RENUMBER` renumbers with its defaults.
* `akbasic_parse_arglist` insists on at least one argument, which is right for
* every other verb that uses it.
*/
akerr_ErrorContext *akbasic_parse_optional_arglist(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *expr = NULL;
akbasic_Token *operator_ = NULL;
akbasic_Token *peeked = NULL;
peeked = akbasic_parser_peek(parser);
if ( peeked != NULL && peeked->tokentype != AKBASIC_TOK_COLON ) {
PASS(errctx, akbasic_parse_arglist(parser, dest));
SUCCEED_RETURN(errctx);
}
PASS(errctx, akbasic_parser_previous(parser, &operator_));
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, operator_->lexeme, NULL));
*dest = expr;
SUCCEED_RETURN(errctx);
}
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
akerr_ErrorContext *akbasic_parse_let(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
/*
* LET is optional in this dialect and in Commodore BASIC 7.0. Assignment is
* handled by expression evaluation, so LET parses as a bare assignment and
* its exec handler does nothing.
*/
PASS(errctx, akbasic_parser_assignment(parser, dest));
SUCCEED_RETURN(errctx);
}
/* LABEL and DIM share a shape: the verb, then one identifier. */
static akerr_ErrorContext *parse_verb_with_identifier(akbasic_Parser *parser, const char *verbname, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *identifier = NULL;
akbasic_ASTLeaf *command = NULL;
PASS(errctx, akbasic_parser_primary(parser, &identifier));
FAIL_ZERO_RETURN(errctx, akbasic_leaf_is_identifier(identifier), AKBASIC_ERR_SYNTAX,
"Expected identifier");
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, verbname, identifier));
*dest = command;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_parse_label(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
PASS(errctx, parse_verb_with_identifier(parser, "LABEL", dest));
SUCCEED_RETURN(errctx);
}
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
/**
* @brief `DIM NAME(SIZES)`, or `DIM NAME@ AS TYPE` / `DIM NAME@ AS PTR TO TYPE`.
*
* The `AS` clause is read here and parked on the *command* leaf: `literal_string`
* takes the type name and `literal_int` records whether `PTR TO` was written.
* Both fields are free on a command leaf, which is the same reasoning the field
* leaf uses for `.left` -- this file's header note about three defects from one
* overloaded link field is about giving a field two meanings on the *same* leaf
* type, not about using an unused one.
*
* `AS`, `PTR` and `TO` are matched by lexeme rather than promoted to keywords.
* Making them reserved words would break any existing program with a variable
* called `AS#`, and they are only meaningful in this one position.
*/
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator A pointer is a distinct declared kind rather than a mode a structure can be in, which is what "strict" means here: `.` requires a structure on its left and `->` requires a pointer, neither stands in for the other, and both refusals name the operator the program should have used. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data. Assignment still copies. POINT is the only way to share, so a program that never writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a message saying to POINT it instead, rather than quietly becoming the one assignment in the language that does not copy. PTR TO is also the only way a TYPE may refer to itself, since by value it would have no finite size. That is what makes a linked list possible, and tests/language/structures/pointers.bas builds one, walks it and renders it. Rendering follows pointers, so it needs a depth bound where copying does not: copy stops at a pointer by construction, but two nodes pointing at each other is easy to write and PRINT would not come back. Four levels, chosen so the bound bites before the 256-byte render buffer does -- otherwise a cycle would stop because it ran out of room rather than because it was told to. Three things the work turned up, all now pinned by tests: A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for every field. Slots now take the type their field declared, so it reads as zeros. Adding POINT as a verb makes POINT unusable as a type name, and the parser reported that as "Expected expression or literal" pointing at the line rather than the problem. The prescan now refuses a reserved word as a type name and says so. Field names follow the same reserved-word rule as variable names, enforced by the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#` is a bad variable name. Recorded rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
/**
* @brief `POINT P@ AT target`.
*
* Two expressions with a word between them rather than a comma, because that is
* how it reads: the verb says what is being done and `AT` says which way round
* the two operands go. `POINT P@, A@` would read equally well in either
* direction, and getting it backwards would silently point the wrong thing.
*
* The two are chained through `.next`, the argument link, so the runtime reaches
* them with akbasic_leaf_first_argument() like any other verb's operands.
*/
akerr_ErrorContext *akbasic_parse_point(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *pointer = NULL;
akbasic_ASTLeaf *target = NULL;
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *command = NULL;
akbasic_Token *word = NULL;
PASS(errctx, akbasic_parser_expression(parser, &pointer));
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER),
AKBASIC_ERR_SYNTAX, "Expected POINT <pointer> AT <structure>");
PASS(errctx, akbasic_parser_previous(parser, &word));
FAIL_ZERO_RETURN(errctx, (strcasecmp(word->lexeme, "AT") == 0), AKBASIC_ERR_SYNTAX,
"Expected AT after POINT <pointer>, not %s", word->lexeme);
PASS(errctx, akbasic_parser_expression(parser, &target));
pointer->next = target;
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
arglist->right = pointer;
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, "POINT", arglist));
*dest = command;
SUCCEED_RETURN(errctx);
}
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
/**
* @brief `TYPE NAME`, whose body was already read by the prescan.
*
* Only the opening line is ever parsed: the verb jumps past its own `END TYPE`,
* so the field lines never reach the parser at all. That is deliberate -- a
* field line is a declaration, and `W#` on its own would otherwise evaluate as a
* bare identifier and quietly create a global called `W#`.
*/
akerr_ErrorContext *akbasic_parse_type(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
PASS(errctx, parse_verb_with_identifier(parser, "TYPE", dest));
SUCCEED_RETURN(errctx);
}
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
akerr_ErrorContext *akbasic_parse_dim(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
akbasic_ASTLeaf *command = NULL;
akbasic_Token *word = NULL;
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
PASS(errctx, parse_verb_with_identifier(parser, "DIM", dest));
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
command = *dest;
if ( !akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER) ) {
SUCCEED_RETURN(errctx);
}
PASS(errctx, akbasic_parser_previous(parser, &word));
FAIL_ZERO_RETURN(errctx, (strcasecmp(word->lexeme, "AS") == 0), AKBASIC_ERR_SYNTAX,
"Expected AS after DIM %s, not %s", command->right->identifier, word->lexeme);
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER),
AKBASIC_ERR_SYNTAX, "Expected a type name after AS");
PASS(errctx, akbasic_parser_previous(parser, &word));
if ( strcasecmp(word->lexeme, "PTR") == 0 ) {
command->literal_int = 1;
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator A pointer is a distinct declared kind rather than a mode a structure can be in, which is what "strict" means here: `.` requires a structure on its left and `->` requires a pointer, neither stands in for the other, and both refusals name the operator the program should have used. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data. Assignment still copies. POINT is the only way to share, so a program that never writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a message saying to POINT it instead, rather than quietly becoming the one assignment in the language that does not copy. PTR TO is also the only way a TYPE may refer to itself, since by value it would have no finite size. That is what makes a linked list possible, and tests/language/structures/pointers.bas builds one, walks it and renders it. Rendering follows pointers, so it needs a depth bound where copying does not: copy stops at a pointer by construction, but two nodes pointing at each other is easy to write and PRINT would not come back. Four levels, chosen so the bound bites before the 256-byte render buffer does -- otherwise a cycle would stop because it ran out of room rather than because it was told to. Three things the work turned up, all now pinned by tests: A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for every field. Slots now take the type their field declared, so it reads as zeros. Adding POINT as a verb makes POINT unusable as a type name, and the parser reported that as "Expected expression or literal" pointing at the line rather than the problem. The prescan now refuses a reserved word as a type name and says so. Field names follow the same reserved-word rule as variable names, enforced by the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#` is a bad variable name. Recorded rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:48:09 -04:00
/*
* `TO` is already a verb -- FOR ... TO owns it -- so it arrives as a
* command token rather than an identifier. Matched by lexeme here for
* the same reason AS and PTR are: these three words mean something only
* in this one position, and reserving them would break any program with
* a variable called TO#.
*/
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:39:47 -04:00
AKBASIC_ERR_SYNTAX, "Expected TO after PTR");
PASS(errctx, akbasic_parser_previous(parser, &word));
FAIL_ZERO_RETURN(errctx, (strcasecmp(word->lexeme, "TO") == 0), AKBASIC_ERR_SYNTAX,
"Expected PTR TO TYPENAME, not PTR %s", word->lexeme);
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER),
AKBASIC_ERR_SYNTAX, "Expected a type name after PTR TO");
PASS(errctx, akbasic_parser_previous(parser, &word));
}
snprintf(command->literal_string, sizeof(command->literal_string), "%s", word->lexeme);
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
SUCCEED_RETURN(errctx);
}
/*
* DEF NAME (A, ...) [= expression]
* COMMAND IDENTIFIER ARGUMENTLIST [ASSIGNMENT EXPRESSION]
*
* With an `=` the function is a single expression. Without one it is a
* multi-line subroutine whose body starts on the next line and ends at RETURN,
* so the environment is told to skip forward to that RETURN rather than execute
* the body during the definition.
*/
Let DEF take structure parameters, and give call scopes back DEF AREA(S@ AS RECT) = S@.W# * S@.H# DEF POKEIT(P@ AS PTR TO RECT) A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused: @ says "a structure" without saying which, so it does not state a contract the way S$ does, and accepting it would mean checking fields at the call rather than at the declaration -- which is the hole naming the type closes. The cost is that there are no generic functions, and that is a real loss rather than an oversight. Passing is by value, because a parameter is bound by assignment and assignment copies; a pointer parameter copies its reference and lets a function change its caller's record on purpose. Neither is a special rule. What a structure parameter does need is its storage prepared before the copy, since a structure variable is a run of slots and there is nothing to copy into until the run exists. A DEF parameter list is no longer parsed as an argument list, because a parameter is a declaration rather than an expression: `S@ AS RECT` stopped that parser dead with "Unbalanced parenthesis". akbasic_value_is_truthy() learned that a pointer is true when it points at something, which had to come with this. Without it there is no way to test for the end of a list at all -- comparing a pointer to 0 reads a numeric field it does not carry and answers whatever that field held. A structure is deliberately given no truth value: it always exists, so the question has no answer worth guessing at. And a regression I introduced last commit, plus the older one underneath it. prev_environment() released a scope but not the variables the scope created, so a call leaked one slot per parameter and two hundred calls exhausted the 128-slot pool. Giving each DEF call its own scope made that reachable; it was there for GOSUB all along, measured on a stashed build -- a subroutine with a local of its own failed after about 128 calls before any of this work. The release is safe because a scope's table holds only what it created, and it is the variable slot that comes back rather than its storage, so a pointer into a record DIMmed in that scope stays sound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
/**
* @brief A `DEF` parameter list, which is not an ordinary argument list.
*
* `akbasic_parser_argument_list()` parses *expressions*, and a parameter is a
* declaration -- `S@ AS RECT` is not an expression and stops that parser dead
* with "Unbalanced parenthesis". So this reads the list itself.
*
* **A structure parameter must name its type.** `@` says "a structure" without
* saying which: three primitive types fit in three suffix characters and N
* declared types do not fit in one, so `X$` fully states its contract and `X@`
* does not. A bare `X@` is refused rather than treated as "any structure",
* because that is duck typing and it puts back exactly the hole naming the type
* closes. The cost is that there are no generic functions, and it is a real one.
*
* The type name is parked on the parameter leaf in `literal_string`, with
* `literal_int` recording `PTR TO` -- the same two free fields, for the same
* reason, that `DIM ... AS` uses.
*/
static akerr_ErrorContext *parse_def_parameters(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *param = NULL;
akbasic_ASTLeaf *tail = NULL;
akbasic_Token *word = NULL;
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_LEFT_PAREN),
AKBASIC_ERR_SYNTAX, "Expected an argument list after DEF <name>");
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
do {
PASS(errctx, akbasic_parser_primary(parser, &param));
FAIL_ZERO_RETURN(errctx,
(param != NULL &&
(param->leaftype == AKBASIC_LEAF_IDENTIFIER_STRING ||
param->leaftype == AKBASIC_LEAF_IDENTIFIER_INT ||
param->leaftype == AKBASIC_LEAF_IDENTIFIER_FLOAT ||
param->leaftype == AKBASIC_LEAF_IDENTIFIER_STRUCT)),
AKBASIC_ERR_SYNTAX,
"Only variable identifiers are valid arguments for DEF");
if ( param->leaftype == AKBASIC_LEAF_IDENTIFIER_STRUCT ) {
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER),
AKBASIC_ERR_SYNTAX,
"%s must name its type: %s AS TYPENAME, or %s AS PTR TO TYPENAME",
param->identifier, param->identifier, param->identifier);
PASS(errctx, akbasic_parser_previous(parser, &word));
FAIL_ZERO_RETURN(errctx, (strcasecmp(word->lexeme, "AS") == 0), AKBASIC_ERR_SYNTAX,
"Expected AS after %s, not %s", param->identifier, word->lexeme);
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER),
AKBASIC_ERR_SYNTAX, "Expected a type name after AS");
PASS(errctx, akbasic_parser_previous(parser, &word));
if ( strcasecmp(word->lexeme, "PTR") == 0 ) {
param->literal_int = 1;
/* `TO` is already a verb -- FOR ... TO owns it -- so it arrives
as a command token rather than an identifier. */
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
AKBASIC_ERR_SYNTAX, "Expected TO after PTR");
PASS(errctx, akbasic_parser_previous(parser, &word));
FAIL_ZERO_RETURN(errctx, (strcasecmp(word->lexeme, "TO") == 0), AKBASIC_ERR_SYNTAX,
"Expected PTR TO TYPENAME, not PTR %s", word->lexeme);
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_IDENTIFIER),
AKBASIC_ERR_SYNTAX, "Expected a type name after PTR TO");
PASS(errctx, akbasic_parser_previous(parser, &word));
}
snprintf(param->literal_string, sizeof(param->literal_string), "%s", word->lexeme);
}
if ( tail == NULL ) {
arglist->right = param;
} else {
tail->next = param;
}
tail = param;
} while ( akbasic_parser_match1(parser, AKBASIC_TOK_COMMA) );
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_RIGHT_PAREN),
AKBASIC_ERR_SYNTAX, "Expected ) after the argument list");
*dest = arglist;
SUCCEED_RETURN(errctx);
}
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
akerr_ErrorContext *akbasic_parse_def(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_Runtime *runtime = parser->runtime;
akbasic_ASTLeaf *identifier = NULL;
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expression = NULL;
akbasic_ASTLeaf *command = NULL;
akbasic_FunctionDef *fndef = NULL;
size_t i = 0;
PASS(errctx, akbasic_parser_primary(parser, &identifier));
FAIL_ZERO_RETURN(errctx, (identifier->leaftype == AKBASIC_LEAF_IDENTIFIER),
AKBASIC_ERR_SYNTAX, "Expected identifier");
Let DEF take structure parameters, and give call scopes back DEF AREA(S@ AS RECT) = S@.W# * S@.H# DEF POKEIT(P@ AS PTR TO RECT) A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused: @ says "a structure" without saying which, so it does not state a contract the way S$ does, and accepting it would mean checking fields at the call rather than at the declaration -- which is the hole naming the type closes. The cost is that there are no generic functions, and that is a real loss rather than an oversight. Passing is by value, because a parameter is bound by assignment and assignment copies; a pointer parameter copies its reference and lets a function change its caller's record on purpose. Neither is a special rule. What a structure parameter does need is its storage prepared before the copy, since a structure variable is a run of slots and there is nothing to copy into until the run exists. A DEF parameter list is no longer parsed as an argument list, because a parameter is a declaration rather than an expression: `S@ AS RECT` stopped that parser dead with "Unbalanced parenthesis". akbasic_value_is_truthy() learned that a pointer is true when it points at something, which had to come with this. Without it there is no way to test for the end of a list at all -- comparing a pointer to 0 reads a numeric field it does not carry and answers whatever that field held. A structure is deliberately given no truth value: it always exists, so the question has no answer worth guessing at. And a regression I introduced last commit, plus the older one underneath it. prev_environment() released a scope but not the variables the scope created, so a call leaked one slot per parameter and two hundred calls exhausted the 128-slot pool. Giving each DEF call its own scope made that reachable; it was there for GOSUB all along, measured on a stashed build -- a subroutine with a local of its own failed after about 128 calls before any of this work. The release is safe because a scope's table holds only what it created, and it is the variable slot that comes back rather than its storage, so a pointer into a record DIMmed in that scope stays sound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
PASS(errctx, parse_def_parameters(parser, &arglist));
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
PASS(errctx, akbasic_runtime_new_function(runtime, &fndef));
/* Uppercase the name: verbs and function names are case-insensitive. */
FAIL_ZERO_RETURN(errctx, (strlen(identifier->identifier) < sizeof(fndef->name)),
AKBASIC_ERR_BOUNDS, "Function name '%s' is too long", identifier->identifier);
for ( i = 0; i < strlen(identifier->identifier); i++ ) {
char c = identifier->identifier[i];
fndef->name[i] = (char)((c >= 'a' && c <= 'z') ? (c - 'a' + 'A') : c);
}
fndef->name[strlen(identifier->identifier)] = '\0';
if ( akbasic_parser_match1(parser, AKBASIC_TOK_ASSIGNMENT) ) {
PASS(errctx, akbasic_parser_expression(parser, &expression));
PASS(errctx, akbasic_leaf_clone(expression, &fndef->leafpool, &fndef->expression));
} else {
/*
* No expression: the body is the lines that follow. Record where it
* starts and skip to RETURN so the definition itself does not execute
* the body.
*/
fndef->expression = NULL;
PASS(errctx, akbasic_environment_wait_for_command(runtime->environment, "RETURN"));
}
PASS(errctx, akbasic_leaf_clone(arglist, &fndef->leafpool, &fndef->arglist));
fndef->lineno = runtime->environment->lineno + 1;
PASS(errctx, akbasic_symtab_set(&runtime->environment->functions, fndef->name, fndef, 0));
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, "DEF", NULL));
*dest = command;
SUCCEED_RETURN(errctx);
}
/*
* FOR ... TO .... [STEP ...]
* COMMAND ASSIGNMENT EXPRESSION [COMMAND EXPRESSION]
*
* Sets up the loop's environment with the TO and STEP expressions and the first
* body line, then makes it the active environment. The FOR leaf itself carries
* the assignment.
*/
akerr_ErrorContext *akbasic_parse_for(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_Runtime *runtime = parser->runtime;
akbasic_ASTLeaf *assignment = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_Token *operator_ = NULL;
akbasic_Environment *parent = runtime->environment;
akbasic_Environment *newenv = NULL;
int64_t firstline = 0;
PASS(errctx, akbasic_parser_assignment(parser, &assignment));
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
AKBASIC_ERR_SYNTAX,
"Expected FOR (assignment) TO (expression) [STEP (expression)]");
PASS(errctx, akbasic_parser_previous(parser, &operator_));
FAIL_NONZERO_RETURN(errctx, strcmp(operator_->lexeme, "TO"), AKBASIC_ERR_SYNTAX,
"Expected FOR (assignment) TO (expression) [STEP (expression)]");
FAIL_ZERO_RETURN(errctx,
(assignment != NULL && akbasic_leaf_is_identifier(assignment->left)),
AKBASIC_ERR_SYNTAX,
"Expected FOR (assignment) TO (expression) [STEP (expression)]");
firstline = parent->lineno + 1;
/*
* The loop body is scanned against the *parent* environment's token stream,
* so the new environment cannot become active until parsing is done. Parse
* TO and STEP first, then switch.
*/
PASS(errctx, akbasic_runtime_new_environment(runtime));
newenv = runtime->environment;
runtime->environment = parent;
PASS(errctx, akbasic_parser_expression(parser, &newenv->forToLeaf));
if ( akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND) ) {
PASS(errctx, akbasic_parser_previous(parser, &operator_));
FAIL_NONZERO_RETURN(errctx, strcmp(operator_->lexeme, "STEP"), AKBASIC_ERR_SYNTAX,
"Expected FOR (assignment) TO (expression) [STEP (expression)]");
PASS(errctx, akbasic_parser_expression(parser, &newenv->forStepLeaf));
} else {
/*
* Dartmouth BASIC says not to infer a negative step: it is either given
* explicitly or assumed to be +1.
*/
PASS(errctx, akbasic_parser_new_leaf(parser, &newenv->forStepLeaf));
PASS(errctx, akbasic_leaf_new_literal_int(newenv->forStepLeaf, "1"));
}
/* A NEXT already being awaited means this is an inner loop over the same variable. */
if ( strcmp(parent->waitingForCommand, "NEXT") == 0 ) {
newenv->forNextVariable = parent->forNextVariable;
}
newenv->loopFirstLine = firstline;
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "FOR", assignment));
runtime->environment = newenv;
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* READ VARNAME [, ...]
* COMMAND ARGUMENTLIST
*
* The identifier leaves are deep-copied into the environment, because the DATA
* line that fills them is parsed later and will have overwritten the per-line
* leaf pool by then.
*/
akerr_ErrorContext *akbasic_parse_read(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_Environment *env = parser->runtime->environment;
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *command = NULL;
int i = 0;
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL && arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"Expected identifier");
env->readLeafPool.next = 0;
expr = arglist->right;
for ( i = 0; i < AKBASIC_MAX_LEAVES; i++ ) {
if ( expr == NULL ) {
env->readIdentifierLeaves[i] = NULL;
continue;
}
FAIL_ZERO_RETURN(errctx, akbasic_leaf_is_identifier(expr), AKBASIC_ERR_SYNTAX,
"Expected identifier");
PASS(errctx, akbasic_leaf_clone(expr, &env->readLeafPool, &env->readIdentifierLeaves[i]));
/*
* A cloned identifier keeps its sibling chain, which for READ is the
* *next* identifier. Sever it so evaluating this leaf cannot walk into
* its sibling.
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
*/
env->readIdentifierLeaves[i]->next = NULL;
expr = expr->next;
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
}
env->readReturnLine = env->lineno + 1;
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, "READ", arglist));
*dest = command;
SUCCEED_RETURN(errctx);
}
/*
* DATA LITERAL [, ...]
* COMMAND ARGUMENTLIST
*/
akerr_ErrorContext *akbasic_parse_data(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *command = NULL;
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL && arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"Expected literal");
for ( expr = arglist->right; expr != NULL; expr = expr->next ) {
Accept GRAPHIC CLR and a negative DATA item Two parse handlers refusing something the documentation promises. Landing together because they are the same defect twice -- a verb's own argument shape falling through to a general path that cannot see it -- in one file, found by one program, and verified in one pass. **`GRAPHIC CLR`** is given as `GRAPHIC mode | CLR` in both docs/06-graphics.md and docs/11-verb-reference.md, and was refused: `CLR` is a verb of its own, so the generic arglist path scanned it as a command token and the expression parser answered "Expected expression or literal". `akbasic_parse_graphic()` takes it as this verb's keyword argument and emits mode 5 -- which `akbasic_cmd_graphic()` already treats as "drop the saved shapes and go back to text", so both spellings are one statement and the exec handler is untouched. The documentation was right all along; nothing in it changes. **`DATA -5`** was refused by `akbasic_parse_data()`, and only there: `READ` scans the source text directly (src/data.c) and always returned the -5 intact. So the value was right and *reaching* the statement raised -- which, since section 4 settled that `DATA` at run time is a no-op, is what a program does with every `DATA` line it walks past. A table of coordinates or velocities is full of negative numbers, which is how a game found it. The fix accepts a unary minus over a numeric literal and nothing else: `DATA -A#` is still a mistake worth naming, and `akbasic_leaf_is_literal()` keeps meaning what it says because other callers rely on it. tests/read_data.c covers both mechanisms -- reading a negative item and reaching the line after it -- with a mixed-sign table and a negative float, since the two were never the same code path. TODO.md section 9 items 7 and 8, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:18:53 -04:00
/*
* A negated literal counts. `DATA -5` used to be refused here -- and only
* here: `READ` scans the source text directly (src/data.c) and returned
* the -5 intact, so the value was right and reaching the *statement*
* raised "Expected literal". Since section 4 settled that DATA at run
* time is a no-op, walking past one is exactly what a program does with
* it, and a table of coordinates or velocities is full of them.
* TODO.md section 9 item 8.
*
* Only over a numeric literal: `DATA -A#` is still a mistake worth
* naming, and akbasic_leaf_is_literal() keeps meaning what it says
* because it is used elsewhere.
*/
if ( expr->leaftype == AKBASIC_LEAF_UNARY &&
expr->operator_ == AKBASIC_TOK_MINUS &&
expr->left != NULL &&
(expr->left->leaftype == AKBASIC_LEAF_LITERAL_INT ||
expr->left->leaftype == AKBASIC_LEAF_LITERAL_FLOAT) ) {
continue;
}
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
FAIL_ZERO_RETURN(errctx, akbasic_leaf_is_literal(expr), AKBASIC_ERR_SYNTAX,
"Expected literal");
}
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, "DATA", arglist));
*dest = command;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_parse_poke(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL), AKBASIC_ERR_SYNTAX,
"POKE expected INTEGER, INTEGER");
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, "POKE", arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
/*
* IF relation THEN command [ELSE command]
* becomes BRANCH(expr=relation, left=then_command, right=else_command).
*/
akerr_ErrorContext *akbasic_parse_if(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *relation = NULL;
akbasic_ASTLeaf *then_command = NULL;
akbasic_ASTLeaf *else_command = NULL;
akbasic_ASTLeaf *branch = NULL;
akbasic_Token *operator_ = NULL;
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
/*
* Everything from here to the THEN is a condition, so a lone `=` in it is an
* equality test. Cleared immediately afterwards: the statement the THEN arm
* holds may well be an assignment.
*/
/*
* The whole expression, not just a relation. The reference parses one
* relation here, which is below AND and OR in the grammar chain -- so
* `IF A# = 5 AND B# = 3 THEN` stopped after the first comparison, left the
* AND unconsumed, and reported "Incomplete IF statement" against a line that
* is ordinary BASIC. A condition is an expression.
*/
parser->comparing = true;
PASS(errctx, akbasic_parser_expression(parser, &relation));
parser->comparing = false;
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
AKBASIC_ERR_SYNTAX, "Incomplete IF statement");
PASS(errctx, akbasic_parser_previous(parser, &operator_));
FAIL_NONZERO_RETURN(errctx, strcmp(operator_->lexeme, "THEN"), AKBASIC_ERR_SYNTAX,
"Expected IF ... THEN");
PASS(errctx, akbasic_parser_command(parser, &then_command));
if ( akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND) ) {
PASS(errctx, akbasic_parser_previous(parser, &operator_));
FAIL_NONZERO_RETURN(errctx, strcmp(operator_->lexeme, "ELSE"), AKBASIC_ERR_SYNTAX,
"Expected IF ... THEN ... ELSE ...");
PASS(errctx, akbasic_parser_command(parser, &else_command));
}
PASS(errctx, akbasic_parser_new_leaf(parser, &branch));
PASS(errctx, akbasic_leaf_new_branch(branch, relation, then_command, else_command));
*dest = branch;
SUCCEED_RETURN(errctx);
}
/*
* INPUT "PROMPT" VARIABLE
* COMMAND EXPRESSION IDENTIFIER
*
* The prompt is hung off the identifier's .left, which is where the exec handler
* looks for it.
*/
akerr_ErrorContext *akbasic_parse_input(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *promptexpr = NULL;
akbasic_ASTLeaf *identifier = NULL;
akbasic_ASTLeaf *command = NULL;
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
akbasic_Token *peeked = NULL;
/*
* `INPUT #1, VAR` reads a line from a file channel rather than from the
* sink. Same spelling note as PRINT: the `#` is its own token and the space
* before it is required.
*/
peeked = akbasic_parser_peek(parser);
if ( peeked != NULL && peeked->tokentype == AKBASIC_TOK_HASHMARK ) {
akbasic_ASTLeaf *arglist = NULL;
(void)akbasic_parser_match1(parser, AKBASIC_TOK_HASHMARK);
PASS(errctx, akbasic_parser_new_leaf(parser, &arglist));
arglist->leaftype = AKBASIC_LEAF_ARGUMENTLIST;
arglist->operator_ = AKBASIC_TOK_FUNCTION_ARGUMENT;
PASS(errctx, akbasic_parser_expression(parser, &arglist->right));
FAIL_ZERO_RETURN(errctx, (arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"Expected INPUT #(channel), (variable)");
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMA),
AKBASIC_ERR_SYNTAX, "Expected a comma after INPUT #(channel)");
PASS(errctx, akbasic_parser_primary(parser, &arglist->right->next));
FAIL_ZERO_RETURN(errctx, akbasic_leaf_is_identifier(arglist->right->next),
AKBASIC_ERR_SYNTAX, "Expected INPUT #(channel), (variable)");
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, "INPUT#", arglist));
*dest = command;
SUCCEED_RETURN(errctx);
}
Port the BASIC interpreter from Go to C Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41 .bas files in the reference's corpus produce byte-identical stdout, including the trailing double newline on an error line -- that comes from basicError building a string ending in \n and handing it to Println, and array_outofbounds.txt encodes it. The corpus is driven in place from the submodule as 41 individual CTest cases rather than copied, so it cannot drift from upstream. Eighteen unit tests cover what the corpus cannot reach. Three structural changes carry most of the work. Go's three reflection lookups (Command*, Function*, ParseCommand*) become one sorted dispatch table in src/verbs.c searched with bsearch; adding a verb is a row and two functions. The five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And run(), which owned the process until MODE_QUIT, splits into step() plus a bounded run() -- goal 3 requires a host game to be able to bound execution, and nothing in the library now terminates the process or touches SDL. Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes the corpus runnable with no SDL present; the akgl-backed sink is still to come and is blocked on libakgl having no text-measurement call. src/convert.c exists because libakstdlib's aksl_ato* family cannot report a conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at four sites and turns it into a BASIC error; routing those through aksl_atoi would have turned four diagnosable errors into wrong answers, with VAL("garbage") quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for use here and which are not. Reference defects are reproduced, not fixed: the golden files encode the observed behaviour and a silent correction is a behaviour change. TODO.md section 6 lists sixteen, and tests/known_reference_defects.c asserts the *correct* contract for six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as "unexpectedly passed". Five of the sixteen were found by this port and are new: subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest of the line (a wrong answer, not a refused one); a unary-minus argument inflates a function's arity so ABS(-9) is rejected; a comparison operator in a line's final column is dropped; hex literals never survive the scanner; and the "Reserved word in variable name" check is dead code. Where the reference reaches undefined behaviour by a route that is defined in Go -- an out-of-range shift, a negative string multiplier, integer division by zero -- this raises instead of inheriting the UB. No golden case exercises any of them. The top-level CMakeLists shadows add_test, set_tests_properties and add_custom_target around all three add_subdirectory calls. Without it libakerror's tests land in our suite as Not Run, and its un-namespaced `coverage` target stops a coverage build from configuring at all. Test targets are akbasic_test_<name>: bare test_<name> collides with libakstdlib's, which is what broke libakgl's configure in c2b16d3. ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no warnings under -Wall -Wextra. Branch coverage is not a target, for the reason libakstdlib and libakgl both record: the akerror macros expand into large branch trees at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
PASS(errctx, akbasic_parser_expression(parser, &promptexpr));
PASS(errctx, akbasic_parser_primary(parser, &identifier));
FAIL_ZERO_RETURN(errctx, akbasic_leaf_is_identifier(identifier), AKBASIC_ERR_SYNTAX,
"Expected identifier");
PASS(errctx, akbasic_parser_new_leaf(parser, &command));
PASS(errctx, akbasic_leaf_new_command(command, "INPUT", identifier));
identifier->left = promptexpr;
*dest = command;
SUCCEED_RETURN(errctx);
}