Let C call a BASIC function with values it already has
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m22s
akbasic CI Build / sanitizers (push) Failing after 4m31s
akbasic CI Build / coverage (push) Failing after 3m40s
akbasic CI Build / akgl_build (push) Failing after 22s
akbasic CI Build / mutation_test (push) Has been cancelled

`akbasic_runtime_call_function(obj, name, args, nargs, dest)`. The argument
binding is split out of the call-site handling, so
`akbasic_runtime_user_function()` becomes evaluate-the-leaves and then call the
same code -- and a verb that wants to hand a BASIC function four numbers has
somewhere to start, which it did not before. The only entry point took a parsed
AST call site, so calling a function required having been parsed as an
expression.

Behaviour-preserving: both suites pass unmodified. The one deliberate difference
is that the AST path now evaluates *all* the arguments before binding any of
them, where it used to interleave. That is the safer order and it is what
by-value passing means everywhere else -- interleaved, a later argument could see
an earlier one already in the callee's scope.

**Finding, filed as section 6 item 41: a multi-line `DEF` called outside a
running program does not run its body, and says nothing about it.**

    DEF TRIPLE(N#)
    T# = N# * 3
    RETURN T#
    PRINT TRIPLE(14)

At the REPL that prints "(UNDEFINED STRING REPRESENTATION FOR 0)". From a file
the same function answers 42. The multi-line body runs by spinning a line loop
guarded on `mode == AKBASIC_MODE_RUN`, which is true only of a program running
from a file; in REPL mode the loop is skipped and the result is the caller's
zeroed return slot. The single-expression form has no such loop, and every case
in tests/user_functions.c goes through run_program and is therefore in RUN mode,
which is most of why nobody had seen it.

**The obvious fix is wrong and I tried it.** Widening the guard to
`mode != AKBASIC_MODE_QUIT` makes the interpreter *hang* instead of answering
wrongly -- `akbasic_runtime_process_line_run()` does not advance a REPL-mode
runtime the way the loop assumes, so the environment never comes back. Trading a
silent wrong answer for a lock-up is worse, so it is reverted, the reasoning is
in a comment where the next person will try the same thing, and the fix is filed
rather than guessed at.

That bounds this entry point rather than blocking it: it reaches a multi-line
body while a program is running, which is exactly the case a verb calling a
callback is in. The new test asserts the single-expression form from C and says
in a comment why the multi-line one is not asserted, so the omission is a
statement rather than a gap.

What is still not done is the language half -- no verb takes a function, and
nothing resolves a bare word to a function rather than a label. Item 40 now says
so, and says it wants a verb that needs it rather than speculation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
This commit is contained in:
2026-08-02 11:06:45 -04:00
parent a9600c3fcc
commit fdb3421b2a
5 changed files with 205 additions and 20 deletions

View File

@@ -783,6 +783,32 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_new_variable(akbasic_Runtime
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKBASIC_ERR_BOUNDS When every function slot is in use.
*/
/**
* @brief Call a user-defined function with values a caller already has.
*
* The half of a call that is not parsing. akbasic_runtime_user_function()
* evaluates a parsed call site's arguments and then comes here; a **verb** that
* wants to hand a BASIC function four numbers starts here directly, which was
* not possible before -- the only entry point took an AST call site, so calling
* a function required having been parsed as an expression.
*
* The body runs the same way either way: a scope from the environment pool, as
* `GOSUB` takes, re-entrant, with recursion depth answering to
* #AKBASIC_MAX_ENVIRONMENTS. A multi-line definition re-enters the line loop
* synchronously and returns when its RETURN pops back out.
*
* @param obj The runtime.
* @param name The function's name, as `DEF` spelled it.
* @param args Values to bind, already evaluated. May be NULL when @p nargs is 0.
* @param nargs How many. Extra arguments beyond the definition's are ignored,
* which is what the AST path has always done.
* @param dest Receives the result, in the caller's scratch.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When @p obj, @p name or @p dest is NULL.
* @throws AKBASIC_ERR_UNDEFINED When no function of that name is defined.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_call_function(struct akbasic_Runtime *obj, const char *name, akbasic_Value **args, int nargs, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_new_function(akbasic_Runtime *obj, akbasic_FunctionDef **dest);
/**
* @brief File one already-scanned source line under its line number.

View File

@@ -17,6 +17,12 @@
/* Per-environment pools */
#define AKBASIC_MAX_LEAVES 32 /* ~16 operations per source line */
/*
* Arguments one call may carry. A line holds 32 tokens, so a call site cannot
* spell more than a handful anyway; this is the ceiling on the array a call
* builds them in, and it is checked rather than assumed.
*/
#define AKBASIC_MAX_CALL_ARGUMENTS 16
#define AKBASIC_MAX_TOKENS 32
#define AKBASIC_MAX_VALUES 64
#define AKBASIC_MAX_VARIABLES 128