Files
akbasic/src/scanner.c
Tachikoma e9996ab2e5 Reset the scanner's leftover token type between lines
The REM early-exit leaves tokentype holding AKBASIC_TOK_REM, and the scan
loop's post-switch check reads it before the next line's first character
has assigned anything. A line opening with whitespace then re-triggered
the REM break and scanned to nothing: every indented line after a REM was
silently skipped. Numbered programs never saw it -- the line number is the
first token and overwrites the leftover -- which is why the whole golden
corpus missed it and the unnumbered, indented galaga.bas found it.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 18:49:11 -04:00

565 lines
18 KiB
C

/**
* @file scanner.c
* @brief Implements the line tokenizer.
*/
#include <ctype.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akbasic/error.h>
#include <akbasic/scanner.h>
#include <akbasic/verbs.h>
akerr_ErrorContext *akbasic_scanner_zero(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in scanner zero");
obj->current = 0;
obj->start = 0;
obj->hasError = false;
obj->tokentype = AKBASIC_TOK_UNDEFINED;
SUCCEED_RETURN(errctx);
}
/**
* @brief Is the cursor past the end of the line?
*
* The answer leaves through @p dest rather than the return value because
* measuring the line can fail, and a `bool` has nowhere to put that. Same shape
* as symtab.c's `probe`, and for the same reason. See libakstdlib #38.
*/
static akerr_ErrorContext *is_at_end(akbasic_Runtime *obj, bool *dest)
{
PREPARE_ERROR(errctx);
size_t linelen = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in is_at_end");
PASS(errctx, aksl_strlen(obj->line, &linelen));
*dest = (obj->current >= (int)linelen);
SUCCEED_RETURN(errctx);
}
/**
* @brief The character under the cursor.
* @param[out] dest The character. Untouched when there is none.
* @param[out] got Whether there was one. The old `bool` return.
*/
static akerr_ErrorContext *peek(akbasic_Runtime *obj, char *dest, bool *got)
{
PREPARE_ERROR(errctx);
bool atend = false;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL && got != NULL), AKERR_NULLPOINTER,
"NULL argument in peek");
PASS(errctx, is_at_end(obj, &atend));
if ( atend ) {
*got = false;
SUCCEED_RETURN(errctx);
}
*dest = obj->line[obj->current];
*got = true;
SUCCEED_RETURN(errctx);
}
/**
* @brief The character one past the cursor.
* @param[out] dest The character. Untouched when there is none.
* @param[out] got Whether there was one. The old `bool` return.
*/
static akerr_ErrorContext *peek_next(akbasic_Runtime *obj, char *dest, bool *got)
{
PREPARE_ERROR(errctx);
size_t linelen = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL && got != NULL), AKERR_NULLPOINTER,
"NULL argument in peek_next");
PASS(errctx, aksl_strlen(obj->line, &linelen));
if ( (obj->current + 1) >= (int)linelen ) {
*got = false;
SUCCEED_RETURN(errctx);
}
*dest = obj->line[obj->current + 1];
*got = true;
SUCCEED_RETURN(errctx);
}
/*
* The lexeme is the span [start, current), with two special cases the reference
* relies on: at end of line it runs to the end, and a zero-width span yields the
* single character at `start` -- unless we are closing a string literal, where
* an empty span really is the empty string.
*/
static akerr_ErrorContext *get_lexeme(akbasic_Runtime *obj, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
size_t measured = 0;
int linelen = 0;
int span = 0;
PASS(errctx, aksl_strlen(obj->line, &measured));
linelen = (int)measured;
if ( obj->current == linelen ) {
span = linelen - obj->start;
} else if ( obj->start == obj->current ) {
if ( obj->tokentype == AKBASIC_TOK_LITERAL_STRING ) {
dest[0] = '\0';
SUCCEED_RETURN(errctx);
}
span = 1;
} else {
span = obj->current - obj->start;
}
FAIL_ZERO_RETURN(errctx, (span >= 0 && (size_t)span < len), AKBASIC_ERR_BOUNDS,
"Lexeme of %d characters exceeds the %zu character limit", span, len - 1);
PASS(errctx, aksl_memcpy(dest, obj->line + obj->start, (size_t)span));
dest[span] = '\0';
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *add_token(akbasic_Runtime *obj, akbasic_TokenType token, const char *lexeme)
{
PREPARE_ERROR(errctx);
akbasic_Environment *env = obj->environment;
size_t lexemelen = 0;
FAIL_ZERO_RETURN(errctx, (env->nexttoken < AKBASIC_MAX_TOKENS), AKBASIC_ERR_BOUNDS,
"Line %" PRId64 " has more than %d tokens",
env->lineno, AKBASIC_MAX_TOKENS);
PASS(errctx, aksl_strlen(lexeme, &lexemelen));
FAIL_ZERO_RETURN(errctx, (lexemelen < AKBASIC_MAX_LINE_LENGTH), AKBASIC_ERR_BOUNDS,
"Token lexeme exceeds the %d character limit", AKBASIC_MAX_LINE_LENGTH - 1);
env->tokens[env->nexttoken].tokentype = token;
env->tokens[env->nexttoken].lineno = env->lineno;
PASS(errctx, aksl_strcpy(env->tokens[env->nexttoken].lexeme,
sizeof(env->tokens[env->nexttoken].lexeme), lexeme));
env->nexttoken += 1;
SUCCEED_RETURN(errctx);
}
/**
* @brief Consume one more character when it matches, choosing between two token types.
* @param[out] matched Whether the character was consumed. The old `bool` return.
*
* On the chain below `peek`, so it reports the same way. See libakstdlib #38.
*/
static akerr_ErrorContext *match_next_char(akbasic_Runtime *obj, char cm, akbasic_TokenType truetype,
akbasic_TokenType falsetype, bool *matched)
{
PREPARE_ERROR(errctx);
char nc = '\0';
bool got = false;
FAIL_ZERO_RETURN(errctx, (obj != NULL && matched != NULL), AKERR_NULLPOINTER,
"NULL argument in match_next_char");
PASS(errctx, peek(obj, &nc, &got));
if ( !got ) {
/*
* Nothing left to peek at, so the operator is whatever it is on its
* own. The reference returns here *without* setting a type
* (basicscanner.go:272), which silently drops a comparison operator in
* the final column of a line: `A# =` produced one token, not two, and
* the parse error that followed pointed somewhere else entirely.
* TODO.md section 6 item 14.
*/
obj->tokentype = falsetype;
*matched = false;
SUCCEED_RETURN(errctx);
}
if ( nc == cm ) {
obj->current += 1;
obj->tokentype = truetype;
*matched = true;
SUCCEED_RETURN(errctx);
}
obj->tokentype = falsetype;
*matched = false;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *match_string(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
char c = '\0';
bool atend = false;
bool got = false;
for ( ;; ) {
PASS(errctx, is_at_end(obj, &atend));
if ( atend ) {
break;
}
PASS(errctx, peek(obj, &c, &got));
if ( !got ) {
PASS(errctx, akbasic_runtime_error(obj, AKBASIC_ERRCLASS_PARSE,
"UNTERMINATED STRING LITERAL\n"));
obj->hasError = true;
SUCCEED_RETURN(errctx);
}
if ( c == '"' ) {
break;
}
obj->current += 1;
}
obj->tokentype = AKBASIC_TOK_LITERAL_STRING;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *match_number(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
bool linenumber = (obj->environment->nexttoken == 0);
char lexeme[AKBASIC_MAX_LINE_LENGTH];
char c = '\0';
char nc = '\0';
int64_t lineno = 0;
long long converted = 0;
bool hex = false;
bool atend = false;
bool got = false;
obj->tokentype = AKBASIC_TOK_LITERAL_INT;
for ( ;; ) {
PASS(errctx, is_at_end(obj, &atend));
if ( atend ) {
break;
}
PASS(errctx, peek(obj, &c, &got));
if ( c == '.' ) {
PASS(errctx, peek_next(obj, &nc, &got));
if ( !got || !isdigit((unsigned char)nc) ) {
PASS(errctx, akbasic_runtime_error(obj, AKBASIC_ERRCLASS_PARSE,
"INVALID FLOATING POINT LITERAL\n"));
obj->hasError = true;
SUCCEED_RETURN(errctx);
}
obj->tokentype = AKBASIC_TOK_LITERAL_FLOAT;
} else if ( c == 'x' && !hex ) {
/*
* An 'x' in a number run. The reference lets it through and then
* stops at the first hex digit after it (basicscanner.go:318), so
* `0xff` lexed as `0x` followed by an identifier `ff`, the parser's
* base-16 branch was unreachable, and the README's hex support did
* not exist. TODO.md section 6 item 15. Once the 'x' is seen, keep
* going over hex digits.
*
* Accepted anywhere in the run rather than only after a leading `0`,
* which is the reference's rule and is worth keeping: it is what
* makes `1x2` one malformed token that the line-number conversion
* then diagnoses by name, instead of two tokens that fail somewhere
* less helpful.
*/
hex = true;
} else if ( hex ) {
if ( !isxdigit((unsigned char)c) ) {
break;
}
} else if ( !isdigit((unsigned char)c) ) {
break;
}
obj->current += 1;
}
if ( obj->tokentype == AKBASIC_TOK_LITERAL_INT && linenumber ) {
PASS(errctx, get_lexeme(obj, lexeme, sizeof(lexeme)));
ATTEMPT {
CATCH(errctx, aksl_atoll(lexeme, &converted));
lineno = (int64_t)converted;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
char message[AKBASIC_MAX_LINE_LENGTH + 32] = "INTEGER CONVERSION";
int written = 0;
/*
* Reporting can itself fail if the sink is broken, and so can
* formatting the message. Neither leaves anything useful to do here --
* this block is already handling an error -- so both are ignored, the
* message keeps the initialiser above if the format fails, and the flag
* lets the next operation surface it.
*/
IGNORE(aksl_snprintf(&written, message, sizeof(message),
"INTEGER CONVERSION ON '%s'", lexeme));
IGNORE(akbasic_runtime_error(obj, AKBASIC_ERRCLASS_PARSE, message));
obj->hasError = true;
} FINISH(errctx, false);
if ( obj->hasError ) {
SUCCEED_RETURN(errctx);
}
obj->environment->lineno = lineno;
obj->tokentype = AKBASIC_TOK_LINE_NUMBER;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *match_identifier(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
char lexeme[AKBASIC_MAX_LINE_LENGTH];
char basename[AKBASIC_MAX_LINE_LENGTH];
const akbasic_Verb *verb = NULL;
void *fndef = NULL;
bool userfunction = false;
char c = '\0';
size_t used = 0;
bool atend = false;
bool got = false;
obj->tokentype = AKBASIC_TOK_IDENTIFIER;
for ( ;; ) {
PASS(errctx, is_at_end(obj, &atend));
if ( atend ) {
break;
}
PASS(errctx, peek(obj, &c, &got));
if ( isdigit((unsigned char)c) || isalpha((unsigned char)c) ) {
obj->current += 1;
continue;
}
switch ( c ) {
case '@':
obj->tokentype = AKBASIC_TOK_IDENTIFIER_STRUCT;
obj->current += 1;
break;
case '$':
obj->tokentype = AKBASIC_TOK_IDENTIFIER_STRING;
obj->current += 1;
break;
case '%':
obj->tokentype = AKBASIC_TOK_IDENTIFIER_FLOAT;
obj->current += 1;
break;
case '#':
obj->tokentype = AKBASIC_TOK_IDENTIFIER_INT;
obj->current += 1;
break;
default:
break;
}
break;
}
PASS(errctx, get_lexeme(obj, lexeme, sizeof(lexeme)));
/*
* The verb table is searched on the *base* name, with any type suffix
* stripped. The reference searches with the suffix still attached
* (basicscanner.go:349), so `PRINT$` misses every table, the "Reserved word
* in variable name" branch below is dead code, and `PRINT$ = 1` is quietly
* accepted as an ordinary string variable. TODO.md section 6 item 16.
*/
PASS(errctx, aksl_strcpy(basename, sizeof(basename), lexeme));
PASS(errctx, aksl_strlen(basename, &used));
if ( obj->tokentype != AKBASIC_TOK_IDENTIFIER && used > 0 ) {
basename[used - 1] = '\0';
}
PASS(errctx, akbasic_verb_lookup(basename, &verb));
ATTEMPT {
CATCH(errctx, akbasic_environment_get_function(obj->environment, lexeme, &fndef));
userfunction = true;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_KEY) {
userfunction = false;
} FINISH(errctx, true);
if ( obj->tokentype == AKBASIC_TOK_IDENTIFIER ) {
if ( verb != NULL ) {
obj->tokentype = verb->tokentype;
} else if ( userfunction ) {
obj->tokentype = AKBASIC_TOK_FUNCTION;
}
} else if ( verb != NULL ) {
/*
* A suffixed identifier that collides with a verb or function name.
* PRINT$ is not a variable.
*/
PASS(errctx, akbasic_runtime_error(obj, AKBASIC_ERRCLASS_SYNTAX,
"Reserved word in variable name\n"));
obj->hasError = true;
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_scanner_scan(akbasic_Runtime *obj, const char *line, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
char lexeme[AKBASIC_MAX_LINE_LENGTH];
char c = '\0';
bool done = false;
bool atend = false;
bool matched = false;
size_t linelen = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in scan");
FAIL_ZERO_RETURN(errctx, (line != NULL), AKERR_NULLPOINTER, "NULL line in scan");
PASS(errctx, aksl_strlen(line, &linelen));
FAIL_ZERO_RETURN(errctx, (linelen < AKBASIC_MAX_LINE_LENGTH), AKBASIC_ERR_BOUNDS,
"Source line of %zu characters exceeds the %d character limit",
linelen, AKBASIC_MAX_LINE_LENGTH - 1);
PASS(errctx, aksl_strcpy(obj->line, sizeof(obj->line), line));
PASS(errctx, akbasic_environment_zero_parser(obj->environment));
obj->current = 0;
obj->start = 0;
obj->hasError = false;
/*
* The `REM` early-exit below leaves `tokentype` holding AKBASIC_TOK_REM,
* and the loop's post-switch check reads it before the first character of
* the *next* line has assigned anything. A line whose first character
* carries no token of its own -- leading whitespace -- then re-triggered
* the REM break and scanned to nothing: every indented line after a REM
* was silently skipped. A numbered program never saw it, because the line
* number is the first token and overwrites the leftover.
*/
obj->tokentype = AKBASIC_TOK_UNDEFINED;
/*
* Cleared here rather than by each caller, so the flag always describes the
* line this call just scanned. It used to be cleared only in
* process_line_repl(); the loading paths never touched it, which was
* harmless while they ignored it and is not now that they decide a line's
* number by it.
*/
obj->hadlinenumber = false;
for ( ;; ) {
PASS(errctx, is_at_end(obj, &atend));
if ( atend || done ) {
break;
}
c = obj->line[obj->current];
obj->current += 1;
switch ( c ) {
case '@': obj->tokentype = AKBASIC_TOK_ATSYMBOL; break;
case '^': obj->tokentype = AKBASIC_TOK_CARAT; break;
case '(': obj->tokentype = AKBASIC_TOK_LEFT_PAREN; break;
case ')': obj->tokentype = AKBASIC_TOK_RIGHT_PAREN; break;
case '+': obj->tokentype = AKBASIC_TOK_PLUS; break;
/*
* `->` reaches a field through a pointer, so a minus has to look ahead
* one character before it can call itself a minus. Nothing else in the
* language begins `->`, and `A# - >` is not an expression, so there is
* no spelling this makes ambiguous.
*/
case '-':
PASS(errctx, match_next_char(obj, '>', AKBASIC_TOK_ARROW, AKBASIC_TOK_MINUS, &matched));
break;
case '/': obj->tokentype = AKBASIC_TOK_LEFT_SLASH; break;
case '*': obj->tokentype = AKBASIC_TOK_STAR; break;
case ',': obj->tokentype = AKBASIC_TOK_COMMA; break;
case ':': obj->tokentype = AKBASIC_TOK_COLON; break;
/*
* A field separator. It only reaches this switch when it is *not* part
* of a number: match_number() is entered on a digit and consumes its own
* decimal point, so `1.5` never gets here and `P@.X#` always does.
*/
case '.': obj->tokentype = AKBASIC_TOK_DOT; break;
/*
* MOVSPR's two separators. Both were UNKNOWN TOKEN until sprites, so
* scanning them breaks nothing -- and a `#` only reaches this switch
* when it is not an identifier's type suffix, which match_identifier
* consumes before returning.
*/
case ';': obj->tokentype = AKBASIC_TOK_SEMICOLON; break;
case '#': obj->tokentype = AKBASIC_TOK_HASHMARK; break;
case '[': obj->tokentype = AKBASIC_TOK_LEFT_SQUAREBRACKET; break;
case ']': obj->tokentype = AKBASIC_TOK_RIGHT_SQUAREBRACKET; break;
case '=':
PASS(errctx, match_next_char(obj, '=', AKBASIC_TOK_EQUAL, AKBASIC_TOK_ASSIGNMENT, &matched));
break;
case '<':
PASS(errctx, match_next_char(obj, '=', AKBASIC_TOK_LESS_THAN_EQUAL,
AKBASIC_TOK_LESS_THAN, &matched));
if ( !matched ) {
PASS(errctx, match_next_char(obj, '>', AKBASIC_TOK_NOT_EQUAL,
AKBASIC_TOK_LESS_THAN, &matched));
}
break;
case '>':
PASS(errctx, match_next_char(obj, '=', AKBASIC_TOK_GREATER_THAN_EQUAL,
AKBASIC_TOK_GREATER_THAN, &matched));
break;
case '"':
obj->start = obj->current;
PASS(errctx, match_string(obj));
break;
case '\t':
case ' ':
obj->start = obj->current;
break;
case '\r':
case '\n':
done = true;
break;
default:
if ( isdigit((unsigned char)c) ) {
PASS(errctx, match_number(obj));
} else if ( isalpha((unsigned char)c) ) {
PASS(errctx, match_identifier(obj));
} else {
char message[AKBASIC_MAX_LINE_LENGTH];
int written = 0;
PASS(errctx, aksl_snprintf(&written, message, sizeof(message),
"UNKNOWN TOKEN %c\n", c));
PASS(errctx, akbasic_runtime_error(obj, AKBASIC_ERRCLASS_PARSE, message));
obj->hasError = true;
obj->start = obj->current;
}
break;
}
if ( done ) {
break;
}
if ( obj->tokentype != AKBASIC_TOK_UNDEFINED && !obj->hasError ) {
if ( obj->tokentype == AKBASIC_TOK_REM ) {
/* Everything after REM is a comment. Stop, keeping the line intact. */
break;
} else if ( obj->tokentype == AKBASIC_TOK_LINE_NUMBER ) {
obj->hadlinenumber = true;
/*
* The line number is not kept as a token. Rewrite the line to
* everything after it, minus leading spaces, and restart the
* cursor -- the REPL reads the rewritten line back out and
* stores *that* as the program text.
*/
int skip = obj->current;
size_t tail = 0;
while ( obj->line[skip] == ' ' ) {
skip += 1;
}
PASS(errctx, aksl_strlen(obj->line + skip, &tail));
PASS(errctx, aksl_memmove(obj->line, obj->line + skip, tail + 1));
obj->current = 0;
} else {
PASS(errctx, get_lexeme(obj, lexeme, sizeof(lexeme)));
PASS(errctx, add_token(obj, obj->tokentype, lexeme));
if ( obj->tokentype == AKBASIC_TOK_LITERAL_STRING ) {
/* Scanning stopped on the closing quote; step past it. */
obj->current += 1;
}
}
obj->tokentype = AKBASIC_TOK_UNDEFINED;
obj->start = obj->current;
}
}
if ( dest != NULL ) {
PASS(errctx, aksl_strlen(obj->line, &linelen));
FAIL_ZERO_RETURN(errctx, (linelen < len), AKBASIC_ERR_BOUNDS,
"Scanned line does not fit the caller's buffer");
PASS(errctx, aksl_strcpy(dest, len, obj->line));
}
SUCCEED_RETURN(errctx);
}