Implement generators: GEN, EMIT, END GEN, FOR EACH and DO EACH (issue 57)
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m32s
akbasic CI Build / coverage (push) Successful in 4m7s
akbasic CI Build / sanitizers (push) Successful in 4m42s
akbasic CI Build / akgl_build (push) Successful in 8m12s
akbasic CI Build / mutation_test (push) Successful in 23m3s

Adds generator support per the plan in issue 57:

- environment.h: isGenerator/generatorFn on a GEN call's own environment,
  isEachLoop and forGeneratorEnv on a FOR EACH/DO EACH loop's own
  environment.
- runtime.c: splits akbasic_runtime_prev_environment() into
  akbasic_runtime_detach_environment() (return to parent without releasing)
  and akbasic_runtime_release_environment() (give variables and the pool
  slot back, on any environment); prev_environment() is now the two in
  sequence. akbasic_runtime_call_function() refuses to call a GEN like an
  ordinary function.
- verbs.c/verbs.h/scanner: new keywords GEN, EMIT, EACH, IN and the compound
  verb "END GEN" (built by a new akbasic_parse_end(), the same trick
  akbasic_parse_print() uses for PRINT #).
- parser_commands.c: akbasic_parse_gen() (modelled on multi-line DEF),
  akbasic_parse_end(), and EACH branches in akbasic_parse_for()/
  akbasic_parse_do().
- runtime_generator.c (new): akbasic_cmd_gen, akbasic_cmd_emit,
  akbasic_cmd_end_gen, and the invoke/pump/release machinery FOR EACH, DO
  EACH, NEXT and LOOP share. EMIT walks up to the nearest isGenerator
  ancestor rather than assuming it is standing directly in the GEN's own
  call frame, because a GEN body may nest its own FOR/DO/GOSUB around an
  EMIT -- the issue's own ROOMOBJECTS example does exactly that.
- runtime_commands.c/runtime_structure.c: EACH branches in cmd_for/cmd_do,
  matching EACH branches in cmd_next/cmd_loop, and forGeneratorEnv release
  on every path that can abandon a live generator (EXIT, a NEXT that pops
  for a mismatched loop variable).

Deviates from the plan in one place: FunctionDef gained an isGenerator flag
(not in the plan's field list) because refusing a GEN called like a
function has to happen before anything is pushed. Relying on EMIT's own
isGenerator check for that case doesn't work: akbasic_runtime_call_function()
drives its own step loop the same way akbasic_runtime_pump_generator() does,
and a BASIC-level error inside that loop is swallowed by process_line_run()
as reported-but-not-propagated, so the call would silently "succeed" with a
meaningless return value instead of failing.

Also: a zero-argument parameter list is not supported by the DEF/GEN
parameter parser this reuses (a pre-existing limitation, not
generator-specific); every generator in the tests takes at least one
parameter as a result.

Tests: tests/generators.c (pool exhaustion under repeated EXIT, calling a
GEN like a function, EMIT outside a GEN, self-recursion, sibling/nested
invocations) and tests/language/flowcontrol/generators_*.bas -- the
issue's own ROOMOBJECTS example in both loop shapes, an empty generator,
non-numeric EMIT, nested/interleaved invocations, and three error-path
golden cases. Docs: control-flow chapter 4 gets a GEN/EMIT/FOR EACH/DO EACH
section, the verb reference gets GEN/EMIT/END GEN entries and updated
FOR/DO/NEXT/LOOP/EXIT rows, and architecture chapter 14 documents the
detach/release split and the two-environment generator invocation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 10:02:43 -04:00
parent 11e4e96daf
commit f7e8d4b82b
32 changed files with 1322 additions and 23 deletions

View File

@@ -72,6 +72,37 @@ typedef struct akbasic_Environment
*/
bool exiting;
/*
* Generator state (GEN / EMIT / FOR EACH / DO EACH).
*
* `isGenerator` is set on the environment a GEN call pushes -- the one
* whose body is actually running the GEN's lines, as opposed to the loop's
* own environment. `generatorFn` records *which* GEN it is running, so a
* FOR EACH/DO EACH that would invoke a GEN currently running higher up the
* parent chain (self-recursion) can be told apart from one invoking it
* fresh, or invoking a sibling instance of the same GEN sitting detached in
* someone else's `forGeneratorEnv`. It carries an akbasic_FunctionDef *, kept
* as void * for the same reason akbasic_environment_get_function() does:
* runtime.h includes this header, not the other way around.
*/
bool isGenerator;
void *generatorFn;
/**
* Set on a FOR EACH or DO EACH loop's own environment, distinguishing it
* from a plain FOR/DO for verbs that need to know which kind of loop this
* is -- EXIT, NEXT and LOOP all read it.
*/
bool isEachLoop;
/**
* The generator environment a FOR EACH/DO EACH loop is suspended on
* between iterations -- alive, detached from the step loop, but not
* released, so its own `nextline` still says where to resume. NULL means
* either "not an EACH loop" or "the generator is exhausted": both leave
* nothing to resume, and by the time either becomes true the loop
* environment itself is on its way out too.
*/
struct akbasic_Environment *forGeneratorEnv;
int64_t gosubReturnLine;
/* READ state. The identifier leaves are deep copies, so they need storage. */

View File

@@ -102,6 +102,15 @@ typedef struct
akbasic_ASTLeaf *arglist;
akbasic_ASTLeaf *expression;
int64_t lineno;
/*
* Set by akbasic_parse_gen(), left false by akbasic_parse_def(). GEN and
* DEF share this table (TODO.md's namespace decision for generators), so
* this is what lets akbasic_runtime_call_function() refuse to run a GEN
* called like an ordinary function -- cleanly, before anything is pushed,
* rather than relying on EMIT to fail deep inside a call whose BASIC-level
* error a caller driving its own step loop would not see raised.
*/
bool isGenerator;
/*
* There is deliberately no environment here. It used to be owned by the
* funcdef and reset on every call, which made a function not re-entrant --
@@ -595,6 +604,93 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_new_environment(akbasic_Runti
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_prev_environment(akbasic_Runtime *obj);
/**
* @brief Return control to the active scope's parent, without releasing it.
*
* The half of akbasic_runtime_prev_environment() that pops; the other half,
* akbasic_runtime_release_environment(), gives the scope's variables and its
* pool slot back. Split for EMIT: a generator suspended between iterations
* has to keep existing -- its own `nextline` is where NEXT resumes it -- so
* detaching without releasing is what lets `obj->environment` move on to the
* loop while the generator's scope stays alive, reachable through
* `forGeneratorEnv`.
*
* @param obj Object to initialize, inspect, or modify.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKBASIC_ERR_ENVIRONMENT When the active scope is the root.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_detach_environment(akbasic_Runtime *obj);
/**
* @brief Give a scope's variables and its pool slot back.
*
* The other half of akbasic_runtime_prev_environment(): unlike that function,
* @p env need not be `obj->environment` -- a generator environment sitting
* detached in some loop's `forGeneratorEnv` is released this way once it is
* exhausted or abandoned, without disturbing whatever scope is active now.
*
* @param obj Object to initialize, inspect, or modify.
* @param env The scope to release; must not be NULL and must not be the root.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `env` is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_release_environment(akbasic_Runtime *obj, akbasic_Environment *env);
/**
* @brief Push a GEN's environment, bind its parameters and run it to its first EMIT.
*
* Shared by the EACH branches of `FOR` and `DO`: both look up the same kind of
* call expression (an `AKBASIC_LEAF_FUNCTION` leaf naming a GEN), guard
* against invoking a GEN that is already running higher up this same parent
* chain, evaluate the call's arguments in the caller's scope, bind them into a
* fresh environment the way a function call does, and run that environment
* until EMIT detaches it or END GEN ends it with nothing emitted.
*
* @p loopenv is left in the state a caller checks afterwards:
* `loopenv->forGeneratorEnv` is the live generator environment when
* something was emitted, or NULL when the GEN produced nothing at all.
*
* @param obj Object to initialize, inspect, or modify.
* @param loopenv The FOR EACH/DO EACH loop's own environment; becomes the new
* generator environment's parent.
* @param callexpr The generator call, e.g. `ROOMOBJECTS(CURROOM%)`.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKBASIC_ERR_STATE When the named GEN is already running higher up
* this same parent chain.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_generator_invoke(akbasic_Runtime *obj, akbasic_Environment *loopenv, akbasic_ASTLeaf *callexpr);
/**
* @brief Run a suspended generator until it next detaches.
*
* Shared by the EACH branches of `NEXT` and `LOOP`: `obj->environment` is
* expected to already be the generator environment to resume (a caller sets
* that from `loopenv->forGeneratorEnv` before calling), and this drives the
* step loop until either EMIT detaches it back to @p loopenv with another
* value, or END GEN really ends it -- in which case it releases the
* generator environment itself and clears `loopenv->forGeneratorEnv`.
*
* @param obj Object to initialize, inspect, or modify.
* @param loopenv The FOR EACH/DO EACH loop's own environment, and the pump's
* stopping point.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_pump_generator(akbasic_Runtime *obj, akbasic_Environment *loopenv);
/**
* @brief Release a live, abandoned generator, wherever EMIT left it suspended.
*
* `env` is expected to be a loop's `forGeneratorEnv` -- the resume point, not
* necessarily the GEN's own call frame, since EMIT may have run several
* levels below it inside a FOR/DO/GOSUB the body wrote. Walks up from there,
* releasing every environment through the call frame itself inclusive, so
* nothing above the resume point is left behind.
*
* @param obj Object to initialize, inspect, or modify.
* @param env The suspended resume point to release.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `env` is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_release_generator(akbasic_Runtime *obj, akbasic_Environment *env);
/**
* @brief Report a BASIC error on the current line, in the reference's format.
*