`spr_collisions()` had no test. `tests/sprite_verbs.c` drives the collision path end to end but through a mock backend, so the real overlap arithmetic in `src/sprite_akgl.c` could have changed what `BUMP(1)` reports for every program in existence and the suite would still have printed 110/110. That gap is closed here, before anything touches the arithmetic, so that there is a *before* to compare an *after* against. Six cases against the real akgl backend: nothing defined, two sprites overlapping, edge-to-edge, a hidden sprite, and the x-expand bit doubling the box that collides rather than only the one that draws. Edge-to-edge earns its place -- the test is a strict `<`, a tile-aligned program puts sprites there constantly, and a replacement answering "touching" instead of "overlapping" would change every one of them silently. **The seventh is the cross-shaped overlap**, and it is the one to watch. A tall thin sprite crossing a short wide one overlaps without either rectangle holding a corner of the other; `akgl_collide_rectangles()` is documented as answering "no" there, which is why `src/sprite_akgl.c` does the four comparisons itself rather than calling it. Two further assertions stop that test passing by accident: each sprite is moved clear along the axis it is supposed to be short on, so a sprite that came out the wrong size fails rather than quietly reporting an ordinary overlap. `tests/collision_perf.c` answers the question nobody had measured. The service runs at the top of every interpreter *step* and the frontend takes 256 steps per rendered frame, so a busy program scans up to 256 times a frame over sprites that have not moved. At RelWithDebInfo, scale 10, best of 5: the scan is 96.3 ns at eight overlapping sprites and 19.4 ns at none, against a rendered frame of 1.17 ms. **256 scans is 24.7 us, or 2.1% of a frame, in the pathological case, and 0.42% for a program with no sprites.** So the per-step cadence stays. It is what makes a collision report describe where the sprites have just been moved to rather than where they were, and 2% of a frame in a case no real program reaches is not worth changing when a handler fires for every program that already works. The numbers and that conclusion are in `MAINTENANCE.md` so it does not get re-argued. The benchmark borrows libakgl's `benchutil.h` by include path rather than copying it, the way the fixture font is already borrowed, and is labelled `perf` so `ctest -LE perf` can leave it out. It runs at scale 1 in the ordinary suite -- 1.2 seconds -- because a benchmark nothing ever builds is a benchmark that rots. Both suites green: 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
799 lines
45 KiB
Markdown
799 lines
45 KiB
Markdown
# Maintaining akbasic
|
||
|
||
`README.md` is for people evaluating the interpreter. `docs/` is for people writing BASIC in
|
||
it. This file is for whoever has to change the thing: what the project is trying to be, how
|
||
the build fits together, and the conventions — the ones enforced by a test, and the ones
|
||
enforced by nothing at all and therefore written down here.
|
||
|
||
`CLAUDE.md` is for agents, and it is deliberately thin: it points here. Everything an agent
|
||
is told about this project is in this file, so there is one copy to keep true.
|
||
|
||
One thing is deliberately *not* here. **How the interpreter actually works — the step loop,
|
||
the four modes, the pools, the scopes that double as block state, the two kinds of error,
|
||
and how to debug all of it — is [`docs/14-architecture.md`](docs/14-architecture.md).** This
|
||
file is the conventions for changing the thing; that chapter is the thing.
|
||
|
||
---
|
||
|
||
## What this project is
|
||
|
||
`akbasic` is a **C rewrite of the Go BASIC interpreter in `deps/basicinterpret`**, written in
|
||
the idiom of the `ak*` C libraries it builds on.
|
||
|
||
| Submodule | Language | Role |
|
||
|---|---|---|
|
||
| `deps/basicinterpret` | Go | The implementation that was rewritten; the behavioural spec for questions about semantics |
|
||
| `deps/libakerror` | C | TRY/CATCH-style error contexts — the substrate every other `ak*` library is built on |
|
||
| `deps/libakstdlib` | C | libc and data-structure wrappers that report through `libakerror` |
|
||
| `deps/libakgl` | C | SDL3-based game/graphics library (sprites, text, tilemaps, actors, heap) — supplies all multimedia |
|
||
|
||
### The goals, in priority order
|
||
|
||
1. **Port Go → C.** Reproduce the reference interpreter in C: `akerr_ErrorContext *` returns
|
||
everywhere, fixed-size pools instead of dynamic allocation, library-prefixed symbols,
|
||
one-file CTest executables. The Go code is already written against static pools and
|
||
explicit state structs, so it ports fairly directly — resist "improving" it into
|
||
`malloc`-based data structures. SDL2/SDL2_ttf calls in the Go version become `libakgl`
|
||
calls (`akgl_text_*`, the renderer, the registry), not raw SDL3 calls.
|
||
|
||
2. **Finish the language.** The full Dartmouth BASIC and Commodore 128 BASIC 7.0 verb and
|
||
function set. `deps/basicinterpret/README.md` ends with the original's list of what was
|
||
unimplemented, and that list was the work queue. What remains is in `TODO.md` and is
|
||
summarised for a BASIC programmer in `docs/13-differences.md`. A few entries are
|
||
deliberately out of scope on a modern PC — `BANK`, `FAST`, `MONITOR`, and `SPRDEF`, which
|
||
is an interactive editor rather than a programmable verb. Keep that reasoning rather than
|
||
reviving them.
|
||
|
||
3. **Be embeddable.** The end state is the interpreter linking *into* `libakgl` as a
|
||
scripting engine for game authors. That constrains the design from the start:
|
||
- The interpreter is a library target with a thin `src/main.c` driver on top. The REPL,
|
||
`QUIT` and argv handling belong to the driver, not the library.
|
||
- Interpreter state lives in explicit structs passed by pointer. No file-scope mutable
|
||
globals.
|
||
- **Nothing in the library may terminate the process.** Errors propagate out as
|
||
`akerr_ErrorContext *` for the host game to handle; `FINISH_NORETURN` belongs only in a
|
||
`main()`.
|
||
- The interpreter owns no window, renderer or game loop. It draws through whatever `akgl`
|
||
renderer the host already initialized, and a host must be able to bound execution rather
|
||
than surrender control to a `run()` that never returns.
|
||
|
||
### Missing capabilities get filed upstream, not worked around
|
||
|
||
**When `libakgl` — or `libakstdlib` — cannot supply something a verb needs, do not work
|
||
around it here.** Add a numbered item to that repository's `TODO.md` describing the missing
|
||
API: what the BASIC verb requires, what the `akgl_*` or `aksl_*` entry point should look
|
||
like, and what tests would cover it. Follow the prose-paragraph style of the entries already
|
||
there. Growing the dependency to serve the interpreter is a wanted outcome, not a detour.
|
||
|
||
It works. Four gaps were filed this way — text measurement, immediate-mode drawing, audio,
|
||
and a non-blocking keystroke read — and all four landed upstream as `akgl_text_measure`, the
|
||
`akgl_draw_*` family, `akgl_audio_*` and `akgl_controller_poll_key`. `FILTER` is the one verb
|
||
still blocked on a gap, and `DIRECTORY` is refused pending an `opendir`/`readdir` wrapper in
|
||
`libakstdlib`. Both refuse at execution and say so, rather than being silently ignored: a
|
||
program that asks for a low-pass filter and gets an unfiltered square wave has been lied to.
|
||
|
||
### The Go reference
|
||
|
||
`deps/basicinterpret` is a Commodore BASIC 7.0 / Dartmouth BASIC dialect in Go with SDL2 +
|
||
SDL2_ttf. It is the behavioural spec: when a question about semantics comes up — "what does
|
||
`READ` do when it runs off the end of `DATA`?" — the answer lives in that code.
|
||
|
||
**That project is deprecated and this interpreter is no longer required to match it.**
|
||
`TODO.md` §0.1 retires the byte-for-byte fidelity constraint that several sections of that
|
||
file were originally written on. The corpus stays green as a *regression* suite; the Go
|
||
source stays readable as documentation of what the original did. Neither is binding.
|
||
|
||
**It is not a build or test dependency.** Both configurations have been configured, built and
|
||
run from scratch with it moved out of the tree. Its acceptance corpus is checked in at
|
||
`tests/reference/` and its Commodore font at `assets/fonts/`.
|
||
|
||
```sh norun
|
||
cd deps/basicinterpret
|
||
make # builds ./basic (CGO_ENABLED=1, needs SDL2 + SDL2_ttf dev packages)
|
||
./basic # interactive REPL
|
||
./basic tests/language/functions.bas
|
||
make tests # or: bash ./test.sh
|
||
```
|
||
|
||
#### Its architecture, and the two decisions that shaped ours
|
||
|
||
Scanner → parser → tree-walking runtime, one source line at a time:
|
||
|
||
- `basicscanner.go` — tokenizes a line into `BasicToken`s. Verbs and function names are
|
||
case-insensitive; variable names are case-sensitive.
|
||
- `basicparser.go` / `basicparser_commands.go` — recursive-descent parser producing
|
||
`BasicASTLeaf` nodes; `basicgrammar.go` defines the leaf types. Commands get their own parse
|
||
paths.
|
||
- `basicruntime.go` — owns the source (`[MAX_SOURCE_LINES]BasicSourceLine`), the variable
|
||
pool, the scanner/parser, the environment stack and the SDL window. `commandByReflection()`
|
||
dispatches a verb to a method by name, so adding a verb there was mostly a matter of adding
|
||
a correctly-named method. **We use a sorted dispatch table instead** — see below.
|
||
- `basicenvironment.go` — a scope *and* the per-line working state: variables, functions,
|
||
labels, plus the in-flight state of `IF`/`FOR`/`GOSUB`/`READ`. Environments chain via
|
||
`parent`.
|
||
- `basicvalue.go` / `basicvariable.go` — the strongly-typed value and the named slot. Type is
|
||
carried by the identifier suffix: `#` integer, `%` float, `$` string.
|
||
- `basicruntime_graphics.go` — output goes through an SDL text surface with a cursor, wrapped
|
||
text, scrolling and a print buffer. `Write()` and `Println()` **mirror every line to
|
||
stdout**, and that mirror is the only reason a golden-file suite is possible at all. Our
|
||
`akbasic_TextSink`, and `akbasic_sink_init_tee()` in particular, is the equivalent.
|
||
|
||
1. **Everything is fixed-size and statically allocated.** `main.go` set the budget:
|
||
`MAX_LEAVES`/`MAX_TOKENS` 32 per environment, `MAX_VALUES` 64, `MAX_VARIABLES` 128,
|
||
`MAX_SOURCE_LINES` 9999, `MAX_LINE_LENGTH` 256, `MAX_ARRAY_DEPTH` 64. The 32-leaf ceiling
|
||
is why a line is limited to roughly 16 operations. Ours are in `include/akbasic/types.h`
|
||
and are the same numbers plus three the Go version did not need because it called `make()`.
|
||
2. **`waitingForCommand` drives block structure.** Rather than building nested blocks in the
|
||
AST, the environment records a verb it is skipping forward to (`NEXT`, `RETURN`, …) and
|
||
suppresses execution of intervening lines until it sees it. Loops and branches evaluate
|
||
their condition at the *bottom* of the structure. We reproduce this, and it is the reason a
|
||
whole `FOR ... NEXT` written on one line does not loop — block skipping walks source
|
||
*lines*.
|
||
|
||
Runtime modes are `MODE_REPL`, `MODE_RUN`, `MODE_RUNSTREAM` (piped input) and `MODE_QUIT`;
|
||
`BASIC_TRUE` is `-1` and `BASIC_FALSE` is `0`, per Commodore convention. All of that carried
|
||
over.
|
||
|
||
---
|
||
|
||
## Building
|
||
|
||
```sh norun
|
||
git submodule update --init --recursive
|
||
cmake -S . -B build
|
||
cmake --build build --parallel
|
||
ctest --test-dir build --output-on-failure
|
||
```
|
||
|
||
### Build everything from the submodules, in one tree
|
||
|
||
`libakgl` vendors its *own* copies of `libakerror` and `libakstdlib` (plus SDL3, SDL3_image,
|
||
SDL3_mixer, SDL3_ttf, jansson and semver) under `deps/libakgl/deps/`. Its `CMakeLists.txt`
|
||
guards every dependency with `if(NOT TARGET ...)`, so a top-level build must define
|
||
`akerror::akerror` and `akstdlib::akstdlib` from `deps/libakerror` and `deps/libakstdlib`
|
||
**before** `add_subdirectory(deps/libakgl)`, or the targets are declared twice.
|
||
|
||
That order is load-bearing for a second reason: `deps/libakerror` is at **2.0.1**, whose 2.0.0
|
||
was a source and ABI break carrying an soname (`libakerror.so.2`). `libakstdlib` and `libakgl`
|
||
must be compiled against that header, not a 1.x one, and an installed `libakerror.so.1` must
|
||
not be picked up. The break is quiet if you get it wrong: `__akerr_last_ignored` became
|
||
thread-local and `akerr_next_error()` now returns a context that already holds a reference, so
|
||
a mixed build leaks pool slots or frees one twice rather than failing to link.
|
||
|
||
### Dependency versions and what they promise
|
||
|
||
| Submodule | Version | soname | ABI rule | Version API |
|
||
|---|---|---|---|---|
|
||
| `deps/libakerror` | 2.0.1 | `libakerror.so.2` | major only | **none** — no version macro; `include/akbasic/error.h` feature-tests `AKERR_THREAD_SAFE` and `AKERR_EXIT_STATUS_UNREPRESENTABLE` instead |
|
||
| `deps/libakstdlib` | 0.2.0 | `libakstdlib.so.0.2` | **`MAJOR.MINOR` while major is 0** | `AKSL_VERSION_*`, `aksl_version()`, `AKSL_VERSION_CHECK()` |
|
||
| `deps/libakgl` | 0.7.0 | `libakgl.so.0.7` | **`MAJOR.MINOR` while major is 0** | `AKGL_VERSION*`, `akgl_version()`, `AKGL_VERSION_AT_LEAST()` |
|
||
|
||
For both 0.x libraries the soname carries `MAJOR.MINOR` deliberately: 0.1 and 0.2 are
|
||
*different* ABIs, and both become major-only at 1.0. Do not read `0.1 → 0.2` as a compatible
|
||
bump — both libraries have actually made that jump, so anything built against the 0.1 headers
|
||
must be rebuilt rather than relinked.
|
||
|
||
**`libakstdlib` and `libakgl` move together and have to.** `libakgl` 0.2.0 consumes the
|
||
`libakstdlib` 0.2.0 API, so a tree pinning `libakstdlib` at 0.1.0 while adding `libakgl` by
|
||
`add_subdirectory()` compiles 0.2.0-era code against 0.1.0 headers and fails on `aksl_fwrite`,
|
||
`aksl_fread` and `aksl_realpath`. Bump them as a pair.
|
||
|
||
`akstdlib.h` pulls in `<akstdlib_version.h>`, which CMake **generates into the build tree** and
|
||
installs beside `akstdlib.h` — so link `akstdlib::akstdlib` and let the target carry its
|
||
include directories. Hand-adding `deps/libakstdlib/include` to an include path gets you a
|
||
missing-header error. It records what the caller was *compiled* against, and `aksl_version()`
|
||
reports what actually *loaded*. `AKSL_VERSION_CHECK()` compares them and raises `AKERR_VALUE`
|
||
naming both. **Call it once during initialization**: the soname normally catches a mismatch at
|
||
load time, but a 0.2.0 dropped in under the 0.1 filename loads happily and only the check
|
||
notices. It is a macro on purpose — it must expand at your call site to capture your numbers.
|
||
|
||
`libakgl` followed the same pattern. `project(akgl VERSION ...)` is the single source and
|
||
drives the generated `include/akgl/version.h`, which is `.gitignore`d because `include/`
|
||
precedes the build tree on the include path and a stray copy there would shadow the generated
|
||
one and pin every consumer. It publishes `AKGL_VERSION_AT_LEAST(major, minor, patch)` — the
|
||
compile-time test `libakstdlib` could not write against `libakerror` — and `akgl_version()`.
|
||
|
||
**Version-pinning in `find_package` is asymmetric, and that is deliberate.**
|
||
`find_package(akstdlib 0.1)` and `find_package(akgl 0.1)` both work; each ships a
|
||
`ConfigVersion.cmake` at `SameMinorVersion`, mirroring its soname. `find_package(akerror 1.0)`
|
||
**fails against a correct install**, because `libakerror` ships `akerrorConfig.cmake` and
|
||
`akerrorTargets.cmake` but no `akerrorConfigVersion.cmake`. Ask for `akerror` unversioned. Its
|
||
floor is enforced instead by an `#error` feature-testing `AKERR_FIRST_CONSUMER_STATUS`, which
|
||
`akstdlib.h`, `akgl/error.h` and our own `include/akbasic/error.h` all carry — include any of
|
||
them and you inherit the guard. The missing version file is filed in
|
||
`deps/libakstdlib/TODO.md` §2.3; when it lands, add the `1.0` floor to the `find_dependency`
|
||
calls.
|
||
|
||
### Embedding all three dependencies collides four ways
|
||
|
||
All three libraries register their CTest tests unconditionally and add coverage and mutation
|
||
targets. A top-level build that `add_subdirectory`s them walks into four separate collisions.
|
||
The handling is at the top of `CMakeLists.txt`, and its comments carry the same detail.
|
||
|
||
**1. Duplicate CTest entries.** Every dependency's tests register into *our* suite. Under
|
||
`EXCLUDE_FROM_ALL` their binaries are never built, so each lands as "Not Run" and fails. CMake
|
||
offers no way to un-register a test, and `set_tests_properties` cannot reach across directory
|
||
scopes. We shadow `add_test()` and `set_tests_properties()` for the duration of the
|
||
`add_subdirectory()` calls. `libakstdlib` carries the same shadow but only arms it when *it*
|
||
is top-level, so it does nothing for us — ours has to wrap all three.
|
||
|
||
**Only one project in a tree may shadow `add_test()`, and this is that project.** CMake
|
||
exposes an overridden command as `_name` and chains exactly one level: a second override
|
||
rebinds `_add_test` to the first override and the builtin becomes unreachable to everyone, so
|
||
our own registrations recurse until CMake stops at *"Maximum recursion depth of 1000
|
||
exceeded"*. `libakgl` 0.3.0 briefly shadowed it unconditionally and that is exactly what
|
||
happened.
|
||
|
||
**2. Duplicate test *target* names.** `add_subdirectory` creates a dependency's targets even
|
||
under `EXCLUDE_FROM_ALL`. When `libakstdlib` added `tests/test_version.c` it collided with
|
||
`libakgl`'s and stopped the configure dead:
|
||
|
||
add_executable cannot create target "test_version" because another target with the same
|
||
name already exists.
|
||
|
||
`libakgl` fixed its side by building programs as `akgl_test_<name>` while keeping the bare
|
||
CTest names. `libakstdlib` still uses bare `test_<name>` targets. **Name every test target in
|
||
this repo `akbasic_test_<name>`** — it costs nothing and it is the collision that actually
|
||
stopped a build.
|
||
|
||
**3. Duplicate custom targets.** `libakerror` namespaces its `mutation` target when embedded
|
||
but **not** its `coverage` target, so any coverage-enabled top-level build fails with *"another
|
||
target with the same name already exists"*. We shadow `add_custom_target` and rename that one
|
||
to `akerror_coverage` on the way past. `libakstdlib` (both targets) and `libakgl` (its
|
||
`mutation` target) namespace themselves correctly. **The real fix is upstream in
|
||
`libakerror`** — the same `CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR` test it already
|
||
applies to `mutation` — and it is filed in `deps/libakstdlib/TODO.md` §2.3. Delete the
|
||
workaround when it lands.
|
||
|
||
**4. Stale build trees poison the coverage report.** See below; it is the reason for
|
||
`cmake -S . -B build`.
|
||
|
||
### Build trees stay out of the source directory
|
||
|
||
`gcovr` searches for `.gcda`/`.gcno` under its `--root`, and the dependencies set that to
|
||
their *source* directory — so a leftover instrumented build tree in the source dir is folded
|
||
into the report, and `--object-directory` does not narrow the search. A leftover
|
||
`build-coverage/` makes a freshly configured run fail in `coverage_reset`, before any test
|
||
executes, with `Got function ... on multiple lines`. Because `build*/` is `.gitignore`d, the
|
||
state is easy to reach and hard to see. Always `cmake -S . -B build`.
|
||
|
||
### Optional tooling, and what each one buys
|
||
|
||
Everything builds and tests with cmake and a C compiler. Three tools are optional, and each
|
||
buys a specific test rather than general convenience:
|
||
|
||
| Tool | Buys | Without it |
|
||
|---|---|---|
|
||
| `xdotool` + `script`(1) | `akgl_typing` — types at a real focused SDL window with a real keyboard | The test reports **Skipped**. Nothing fails |
|
||
| `gcovr` | the `coverage` target and its 90%-of-lines gate | `-DAKBASIC_COVERAGE=ON` will not configure |
|
||
| `python3` | `scripts/mutation_test.py` and the `mutation` target | The target is not created |
|
||
|
||
**`akgl_typing` is worth installing `xdotool` for.** It is the only test that exercises the
|
||
path a person actually uses — X11 delivers a key press to a focused window, SDL composes it,
|
||
libakgl rings it, the editor echoes it, the interpreter runs it. Every other keyboard test
|
||
synthesises SDL events, which tests the code *downstream* of SDL and cannot test the code
|
||
upstream of it. That gap shipped a bug: the frontend never called `SDL_StartTextInput()`, so
|
||
SDL emitted no text-input events at all, the line editor dropped every keystroke, and the
|
||
whole suite stayed green because it was pushing those events itself.
|
||
|
||
It needs a real X server and **it steals keyboard focus for about fifteen seconds**. Set
|
||
`AKBASIC_SKIP_INTERACTIVE=1` to skip it while you are using the machine. It skips itself when
|
||
there is no display, no `xdotool`, or no window manager answering — CTest reports `Skipped`
|
||
rather than a failure, because none of those means the answer is no.
|
||
|
||
### The two workflows
|
||
|
||
**`.gitea/workflows/ci.yaml`** runs on every push: the suite, ASan+UBSan, coverage gated at
|
||
90% of lines, and mutation testing over two files. The coverage report is uploaded as a
|
||
`code-coverage` artifact.
|
||
|
||
**`.gitea/workflows/release.yaml`** is manual (`workflow_dispatch`) and is what a release
|
||
runs. It builds the API documentation and uploads it as `api-documentation`, and it mutates
|
||
the *whole* `src/` tree — 3675 mutants, hours of runner time, which is why it is not on the
|
||
push path. It takes two optional inputs: a mutation threshold, and a space-separated file
|
||
list to narrow the run.
|
||
|
||
### API documentation is a gate, not a convenience
|
||
|
||
```sh norun
|
||
doxygen Doxyfile
|
||
```
|
||
|
||
The `Doxyfile` is configured the way `libakgl`'s is, including
|
||
`WARN_AS_ERROR = FAIL_ON_WARNINGS` — a doc block that documents some of a function's
|
||
parameters but not all of them fails the run. Every public declaration under
|
||
`include/akbasic/` carries a `@brief`, a `@param` per parameter, a `@return` and its
|
||
`@throws`. Keep it that way when you add one.
|
||
|
||
---
|
||
|
||
## Tests
|
||
|
||
### Three lists, and two of them invert "passed"
|
||
|
||
`CMakeLists.txt` declares `AKBASIC_TESTS`, `AKBASIC_WILL_FAIL_TESTS` and
|
||
`AKBASIC_KNOWN_FAILING_TESTS`. The first must exit 0. The second aborts by design. The
|
||
third **asserts the correct contract for a defect that is documented in `TODO.md`** and is
|
||
expected to fail.
|
||
|
||
A green `ctest` therefore does not mean defect-free. When a known-failing test starts
|
||
passing, CTest reports "unexpectedly passed" — that is the cue to move it into
|
||
`AKBASIC_TESTS` along with the fix, not to delete it.
|
||
|
||
`AKBASIC_WILL_FAIL_TESTS` is currently empty and is kept declared anyway, so the shape is
|
||
there when it is next needed.
|
||
|
||
The dependencies use the same split under their own prefixes: `AKSL_TESTS` /
|
||
`AKSL_WILL_FAIL_TESTS` / `AKSL_KNOWN_FAILING_TESTS`, and `AKERR_TESTS` /
|
||
`AKERR_WILL_FAIL_TESTS`. `libakstdlib` 0.2.0 fixed all six defects its `TODO.md` §2.1 listed
|
||
and left `AKSL_KNOWN_FAILING_TESTS` empty.
|
||
|
||
### Test target names
|
||
|
||
Every test program builds as `akbasic_test_<name>` while registering under the bare CTest
|
||
name. That is not cosmetic: `add_executable` creates a dependency's targets even under
|
||
`EXCLUDE_FROM_ALL`, and when `libakstdlib` added a `test_version` it collided with
|
||
`libakgl`'s and stopped the configure dead.
|
||
|
||
### The golden corpora
|
||
|
||
`tests/reference/` is the Go implementation's own acceptance suite, byte-compared.
|
||
**Nothing in it is ever edited to suit this interpreter.** If a case fails, either this
|
||
interpreter is wrong or the divergence is deliberate — and a deliberate one goes in
|
||
`TODO.md` and `docs/13-differences.md`, not into the expectation file. `tests/reference/README.md`
|
||
says the same thing at more length.
|
||
|
||
`tests/language/` is ours and may be changed freely. A new language feature needs a
|
||
`.bas`/`.txt` pair there as well as unit tests.
|
||
|
||
### Mutation-check a fix before you believe it
|
||
|
||
Coverage says a line ran; it does not say anything would have noticed if it were wrong.
|
||
That matters more here than usual, because the `akerror` macros expand at their call sites
|
||
and `gcov` attributes them to the caller.
|
||
|
||
The discipline for any fix with a test: **revert the fix, confirm the test fails, restore
|
||
it.** Use a file copy, not `git checkout` — `git checkout -- src` in a loop like this has
|
||
already wiped a session's worth of unrelated edits.
|
||
|
||
```sh norun
|
||
cmake --build build --target mutation # whole tree; hours
|
||
python3 scripts/mutation_test.py --target src/value.c --threshold 70
|
||
```
|
||
|
||
Mutation testing earns its keep. Writing this suite it found that nothing exercised a
|
||
maximum-length string or symbol-table key, so every `MAX - 1` off-by-one in a `strncpy` would
|
||
have gone unnoticed; and that `errno` was never asserted to be cleared before a `strtoll`,
|
||
which is what stops a stale `ERANGE` from failing a perfectly valid conversion.
|
||
|
||
### The dependencies' own harnesses
|
||
|
||
Option prefixes differ per repository, which is the thing that catches people out:
|
||
|
||
```sh norun
|
||
cmake -S . -B build-asan -DAKSL_SANITIZE=ON # libakstdlib
|
||
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON # libakstdlib
|
||
cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug # libakgl
|
||
cmake --build build --target coverage
|
||
cmake --build build --target mutation
|
||
```
|
||
|
||
`deps/libakerror/test.sh` shows the full CI gate for that library: ctest, mutation ≥ 65, and
|
||
coverage ≥ 90 line / ≥ 50 branch. The branch gate in *this* repository was re-ratcheted 45 →
|
||
40 after the `libakerror` 1.0.0 bump, because the new `PREPARE_ERROR`/`FAIL_*` macros expand
|
||
to more branches per call site — a denominator change, not a regression.
|
||
|
||
`rebuild.sh` in `libakgl` and `libakstdlib` installs into the developer-specific prefix
|
||
`/home/andrew/local` and deletes build directories. Use the portable commands above unless
|
||
that exact behaviour is what you want.
|
||
|
||
### Benchmarks, and what the collision scan actually costs
|
||
|
||
`tests/collision_perf.c` is the only benchmark here. It is labelled `perf`, so `ctest -LE perf`
|
||
leaves it out of the ordinary run, and it borrows `deps/libakgl/tests/benchutil.h` by include
|
||
path rather than copying it — the harness is the house convention across these repositories and
|
||
a second copy would be a fork.
|
||
|
||
**Both checked-in build trees are `Debug`, and `-O0` numbers are not worth reading.**
|
||
`benchutil.h` refuses to enforce a budget without `__OPTIMIZE__` for exactly that reason.
|
||
Configure a third tree and run it at scale:
|
||
|
||
```sh norun
|
||
cmake -S . -B build-perf -DAKBASIC_WITH_AKGL=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||
cmake --build build-perf --target akbasic_test_collision_perf
|
||
cd build-perf && AKGL_BENCH_SCALE=10 ctest -L perf
|
||
```
|
||
|
||
The question it was written to settle: `akbasic_collision_service()` runs at the top of every
|
||
interpreter **step**, and the akgl frontend takes `AKBASIC_FRONTEND_STEPS_PER_FRAME` (256) steps
|
||
per rendered frame — so a busy program scans up to 256 times a frame, nearly always over sprites
|
||
that have not moved. Is that worth changing to a per-frame cadence?
|
||
|
||
Measured at `RelWithDebInfo`, scale 10, best of 5:
|
||
|
||
| Row | ns/op |
|
||
|---|---|
|
||
| `spr_collisions`, 0 sprites | 19.4 |
|
||
| `spr_collisions`, 2 sprites | 26.1 |
|
||
| `spr_collisions`, 4 sprites | 40.3 |
|
||
| `spr_collisions`, 8 sprites, all overlapping | 96.3 |
|
||
| one rendered frame, 8 sprites + full text grid | 1,174,081 |
|
||
|
||
**The answer is no.** 256 scans at 96.3 ns is 24.7 µs against a 1.17 ms frame — **2.1%**, and
|
||
that is the pathological case: eight sprites all overlapping, in a program that never blocks and
|
||
therefore burns all 256 steps. The cost a program with no sprites pays is 0.42% of a frame. The
|
||
frame is dominated by the text layer repainting every row it owns, which `TODO.md` already
|
||
records as the real rendering cost.
|
||
|
||
So the per-step cadence stays. It is what makes a collision report describe where the sprites
|
||
have just been moved to rather than where they were (`src/runtime.c`, above the service call),
|
||
and buying 2% of a frame is not worth changing when a handler fires for every program that
|
||
already works. Recorded here rather than argued again.
|
||
|
||
**Read a benchmark as a gap between two rows of the same run, not as an absolute.** libakgl's
|
||
`PERFORMANCE.md` records a whole laptop reading 15% high on a later run, including rows nothing
|
||
had touched.
|
||
|
||
---
|
||
|
||
## Code
|
||
|
||
### The libakerror error convention
|
||
|
||
Mixing conventions breaks error propagation, so new C code follows this one — it is what
|
||
`libakstdlib` and `libakgl` both do.
|
||
|
||
- Any function that can fail returns `akerr_ErrorContext AKERR_NOIGNORE *` and opens with
|
||
`PREPARE_ERROR(e);`.
|
||
- Return with `SUCCEED_RETURN(e)` / `FAIL_RETURN(...)`, never a bare status code.
|
||
- Propagate with `PASS(e, some_call());` when there is nothing local to do.
|
||
- Handle locally with
|
||
`ATTEMPT { CATCH(e, call()); } CLEANUP { } PROCESS(e) { } HANDLE(e, AKERR_X) { } FINISH(e, true);`
|
||
— `FINISH(e, true)` re-raises unhandled errors to the caller.
|
||
- **`CATCH` and the `FAIL_*_BREAK` macros expand to a C `break`**, so they must never appear
|
||
inside a loop or a nested `switch` within an `ATTEMPT`: the `break` would escape only the
|
||
loop, and the rest of the `ATTEMPT` would run with an error pending. Inside a loop use
|
||
`PASS` or `FAIL_*_RETURN`, or move the loop into its own `akerr_ErrorContext *` helper and
|
||
`CATCH` that single call.
|
||
- Never use a `*_RETURN` macro inside an `ATTEMPT` block. It returns past `CLEANUP`, so every
|
||
release, `fclose` and free is skipped.
|
||
- `deps/libakerror/AGENTS.md` is the authoritative statement of the protocol, and
|
||
`deps/libakerror/UPGRADING.md` is required reading before writing an error code.
|
||
|
||
### Error codes
|
||
|
||
`akbasic` owns **512–767** under the owner string `"akbasic"`, claimed by
|
||
`akbasic_error_register()` — which `akbasic_runtime_init()` calls, so a host gets it for free.
|
||
Codes are an `enum` in `include/akbasic/error.h` so they stay compile-time integer constants;
|
||
`HANDLE`'s `case` labels require that. **Add a code and you must name it in
|
||
`akbasic_error_register()`**, or it prints as "Unknown Error" in every stack trace that
|
||
carries it.
|
||
|
||
#### The coordinated range map
|
||
|
||
`libakerror` cannot enumerate its consumers, so this table is the coordination. Keep it
|
||
current, and reserve the whole 256 even where fewer codes are used, so a later addition does
|
||
not need a second reservation:
|
||
|
||
| Owner string | Range | Status |
|
||
|---|---|---|
|
||
| `"libakerror"` | 0 – 255 | reserved by `akerr_init()`; do not touch |
|
||
| *(none)* | — | `libakstdlib` deliberately reserves nothing and defines no codes of its own — it raises `AKERR_*` and propagates `errno`, both inside the reserved band. Its `tests/test_status_registry.c` pins that as a contract, so nobody has to coordinate with it |
|
||
| `"libakgl"` | 256 – 260 | reserved by `akgl_error_init()`; `AKGL_ERR_BASE` … `AKGL_ERR_LIMIT - 1`, five codes |
|
||
| *(free)* | 261 – 511 | headroom for `libakgl` to grow into; do not claim it |
|
||
| `"akbasic"` | 512 – 767 | ours; `AKBASIC_ERR_BASE` is 512 |
|
||
|
||
Values 0–255 are the host errno space plus the `AKERR_*` codes. Consumers allocate from
|
||
`AKERR_FIRST_CONSUMER_STATUS` (256) upward, as **absolute integer constants** — never as
|
||
offsets from `AKERR_LAST_ERRNO_VALUE`, so a libc that grows an errno cannot move them. Every
|
||
library that can coexist in one process reserves its range in its own initializer:
|
||
|
||
```c norun
|
||
akerr_ErrorContext AKERR_NOIGNORE *akerr_reserve_status_range(int first_status, int count, const char *owner);
|
||
akerr_ErrorContext AKERR_NOIGNORE *akerr_register_status_name(const char *owner, int status, const char *name);
|
||
```
|
||
|
||
Both are `AKERR_NOIGNORE`, so a collision is an ordinary error to `CATCH`, `HANDLE` or `PASS`
|
||
out of an init function. Ownership is **enforced**: naming a status in someone else's range
|
||
fails with `AKERR_STATUS_NAME_FOREIGN`, and naming one nobody reserved fails with
|
||
`AKERR_STATUS_NAME_UNRESERVED`. You do not need to call `akerr_init()` first — every registry
|
||
entry point calls it for you, and it does not clear reservations made before it ran. Repeating
|
||
an identical reservation is a no-op; a subset or superset of your own range is not, so reserve
|
||
the whole thing in one call.
|
||
|
||
`akgl_error_init()` has an ordering requirement worth knowing before wiring up a `main()`: it
|
||
must run before anything else in `libakgl`, because a code raised before it registers carries
|
||
no name into its stack trace. `akgl_game_init()` calls it as its first statement, but a
|
||
program driving subsystems directly — which is what the embedded interpreter does — has to
|
||
call it itself. It is idempotent.
|
||
|
||
#### What 1.0.0 removed, and what it still does not solve
|
||
|
||
These no longer exist. Any code, compile definition or documentation referring to them is
|
||
stale:
|
||
|
||
| Removed | Replacement |
|
||
|---|---|
|
||
| `AKERR_MAX_ERR_VALUE` | nothing — the name registry is sparse and takes any `int` |
|
||
| `__AKERR_ERROR_NAMES` | `akerr_name_for_status()`; the table is private to the library now |
|
||
| `AKERR_STATUS_RANGE_OK` / `AKERR_STATUS_NAME_OK` | success is a `NULL` `akerr_ErrorContext *`, like everything else |
|
||
|
||
`libakerror`'s own `TODO.md` §2 says the limit plainly: ownership enforcement covers *naming*,
|
||
which is the part the library mediates. It cannot detect two components compiling the same
|
||
integer into a `HANDLE` `case` label without ever registering a name — that never reaches the
|
||
registry. §3 adds that there is no way to ask who owns a status or to enumerate reservations,
|
||
so the table above is the only tooling there is. Two rules follow:
|
||
|
||
- **Always reserve, even for codes you never name.** Reservation is the only thing that makes
|
||
a collision visible at all.
|
||
- **Never define an error code as an offset from another library's symbol.**
|
||
|
||
Capacity is not a concern: the name registry holds 3072 entries and `akerr_init()` consumes
|
||
about 150; reservations cap at 64 ranges. Both are `PRIVATE` to the `libakerror` target, so
|
||
raising them is a `libakerror` configure-time decision, not something `akbasic` sets.
|
||
**Registration became thread safe in libakerror 2.0.0**, and the rule barely moves.
|
||
`akerr_reserve_status_range()` and `akerr_register_status_name()` are serialized now — two
|
||
threads reserving overlapping ranges cannot both succeed, one gets `AKERR_STATUS_RANGE_OVERLAP`
|
||
naming the winner. What is still yours to coordinate is *re*-registering a name for a status
|
||
another thread may be looking up: `akerr_name_for_status()` returns a pointer into the registry
|
||
rather than a copy, which is what makes it usable from a stack trace, and a second registration
|
||
overwrites that buffer in place. The reader is outside the lock by the time it reads the
|
||
string, so no lock can fix it.
|
||
|
||
So: still do it during single-threaded init, before the host game spawns anything — not
|
||
because the call would race, but because there is one operation in the registry that cannot be
|
||
made safe and this is the discipline that avoids needing it.
|
||
|
||
### Structures reuse the array machinery, deliberately
|
||
|
||
`TYPE` declares a record, so an instance has a known slot count and takes one contiguous
|
||
run from the same value pool `DIM A#(10)` draws from. **There is no structure pool**, and
|
||
adding one would be the wrong instinct: the only new table holds *descriptors* — names and
|
||
slot offsets — and the data goes where array data already goes.
|
||
|
||
Two rules for changing any of it:
|
||
|
||
- **A structure copy must not go through `akbasic_value_clone()`.** Clone copies one slot,
|
||
and one slot holds a *reference* to an instance rather than the instance, so a structure
|
||
taking that path aliases instead of copying — which is the semantics the language
|
||
deliberately does not have. `akbasic_environment_assign()` intercepts first and calls
|
||
`akbasic_struct_copy()`. If you add a place a structure can be assigned, it goes through
|
||
that, not through clone.
|
||
- **A field chain gets its own leaf type and its own link field.** `include/akbasic/grammar.h`
|
||
records three separate defects that came from giving one link field two meanings;
|
||
`AKBASIC_LEAF_FIELD` keeps its base on `.left`, which nothing else on that leaf type uses.
|
||
|
||
`docs/14-architecture.md` has the layout diagram and the three-pass prescan.
|
||
|
||
### Nothing calls malloc
|
||
|
||
`libakgl`'s hard rule, and ours: obtain objects from `akgl_heap_next_*` and release them back,
|
||
and if no heap layer exists for a needed type, add one rather than calling `malloc`. On our
|
||
side of the line every object comes from a fixed pool inside `akbasic_Runtime`, sized by the
|
||
constants in `include/akbasic/types.h`. Exhausting a pool is a diagnosable error, not a crash
|
||
and not a slow leak.
|
||
|
||
### The verb table is sorted, and a test says so
|
||
|
||
`src/verbs.c` is searched with `bsearch`. A mis-sorted table does not fail to compile — it
|
||
silently fails to find a verb, and the symptom is `Unknown command PRINT` a long way from
|
||
the cause. `tests/verbs_table.c` asserts the ordering; adding a verb in the wrong place
|
||
fails there.
|
||
|
||
A builtin's name, arity and handler are one row in that table. Nothing is bootstrapped by
|
||
running a BASIC program of `DEF` statements at startup, the way the Go version did, so the
|
||
interpreter no longer has to be running before the interpreter is ready.
|
||
|
||
### Nothing in the library terminates the process
|
||
|
||
Goal 3. `FINISH_NORETURN` appears only in a `main()` — today the driver's and the
|
||
examples'. A *script's* error is reported through the sink and swallowed; an *interpreter*
|
||
error propagates out as `akerr_ErrorContext *` for the host.
|
||
|
||
Both halves of that need saying, because the second one is easy to get right and the first
|
||
one is easy to get wrong: `process_line_run()` swallowed its context correctly from the
|
||
start while the direct-mode branch of `process_line_repl()` used a bare `PASS`, so a
|
||
`VERIFY` against a file that did not match — an ordinary user answer — tore down the driver
|
||
with a stack trace.
|
||
|
||
### Generated files
|
||
|
||
Never hand-edit `build/` trees, the generated `akerror.h`, `akgl.pc`, or
|
||
`include/akgl/SDL_GameControllerDB.h`. Change the template or the generator.
|
||
|
||
### Style
|
||
|
||
C99, four-space indent, braces on their own line for function bodies, spaces inside
|
||
control-flow parentheses. **Match the surrounding file** — several mix tabs and spaces and
|
||
there is no repo-wide formatter. Do not reformat code you are not otherwise changing;
|
||
style conversions get their own commit.
|
||
|
||
Public symbols take the `akbasic_` prefix, `akbasic_TypeName` for types, `AKBASIC_` for
|
||
macros. `static` helpers drop it. Headers and sources are paired by feature —
|
||
`src/scanner.c` and `include/akbasic/scanner.h`.
|
||
|
||
### Commits
|
||
|
||
Short, imperative, sentence-case subjects describing observable behaviour: *"Fix refcount leak
|
||
and stack-trace buffer overflow"*. Keep them focused, and include the tests with the behaviour
|
||
change. `libakgl`'s `AGENTS.md` requires an agent to add itself — program, model and version —
|
||
as a commit co-author, and this repository follows the same rule.
|
||
|
||
Each dependency carries its own `AGENTS.md` with authoritative per-repo rules. Read the
|
||
relevant one before editing a submodule.
|
||
|
||
---
|
||
|
||
## Editing the documentation
|
||
|
||
**Every fenced block in `README.md` and `docs/*.md` is executed by `ctest`.** The test is
|
||
`docs_examples`; it runs in both build configurations and it fails the build. It caught
|
||
four wrong examples the day it was added — two transcripts showing a leading space
|
||
`PRINT` does not emit, a `struct` in `README.md` that had grown two members hours
|
||
earlier, and a `FILTER` refusal quoted with the wrong wording.
|
||
|
||
An **untagged block is a failure**, not a default. That is deliberate: the way a harness
|
||
like this dies is by quietly matching nothing and passing, so leaving a block untagged is
|
||
a missing decision rather than a free pass.
|
||
|
||
### The tags
|
||
|
||
| Info string | What happens |
|
||
|---|---|
|
||
| `basic` | Written to a file and run. Must exit 0 and print no `? line : CLASS` error |
|
||
| `basic repl` | Fed to a fresh interpreter on **stdin**. The leading `READY` banner is dropped before comparison |
|
||
| `basic norun` | Shown, not run. For fragments and for anything that loops forever |
|
||
| `basic requires=akgl` | Run only in the `-DAKBASIC_WITH_AKGL=ON` build |
|
||
| `basic requires=noakgl` | Run only in the default build. For verbs whose *refusal* is the example |
|
||
| `basic setup=NAME` | Runs `tests/docs_setups/NAME.sh` in the sandbox first |
|
||
| `output` | The **exact stdout** of the block above it, compared byte for byte |
|
||
| `c` | Compiled with `-fsyntax-only -std=gnu99 -Wall -Wextra -Werror` against the real include path |
|
||
| `c wrap=NAME` | The same, with `tests/docs_preludes/NAME.pre` before it and `NAME.post` after |
|
||
| `c excerpt=PATH` | Must appear in `PATH`, ignoring comments and whitespace. Not compiled |
|
||
| `c norun` | Shown, not compiled |
|
||
| `sh` | Run in a sandbox with the built interpreter at `./build/basic`. Must exit 0. A leading `$ ` is stripped |
|
||
| `sh setup=NAME` | The same, after `tests/docs_setups/NAME.sh` |
|
||
| `sh norun` | Shown, not run |
|
||
| `cmake` | **Never executed.** Hand-maintained, by decision |
|
||
| `text` | **Never executed.** A block diagram, or a stack trace captured from a real run |
|
||
| `basic screenshot=NAME` | Also the source of `docs/images/NAME.png`. The harness checks the file exists; `tools/docs_screenshots.sh` is what makes it |
|
||
| `basic size=WxH` | That figure's surface, when 320x200 is not the size the point needs |
|
||
| `basic text=1` | That figure is drawn **with** the text layer, for a program whose output is characters |
|
||
|
||
Attributes combine: `basic requires=akgl setup=ship` is a real tag in `docs/08-sprites.md`.
|
||
|
||
### Figures are output, not assets
|
||
|
||
A chapter about `CIRCLE` wants a picture of what `CIRCLE` draws, and the way a picture
|
||
goes wrong is that it stops being of the code beside it — silently, because a screenshot
|
||
taken by hand still looks like a screenshot long after the verb changed. So a figure is
|
||
generated from the listing it illustrates:
|
||
|
||
```sh norun
|
||
$ cmake --build build-akgl --target docs_screenshots
|
||
```
|
||
|
||
`tools/screenshot.c` is a second, much smaller SDL host — the dummy video driver, a
|
||
software renderer, run the program to completion, read the target back, write a PNG. It
|
||
draws no text layer by default, deliberately: a `READY` in the corner is noise in a figure
|
||
about `BOX`, and skipping it means no font has to be found.
|
||
|
||
**`text=1` turns the text layer on**, for the case that default gets wrong: a figure of a
|
||
program whose output *is* characters. `docs/17-tutorial-breakout.md` builds a game whose
|
||
wall, HUD and messages are all in the grid, and without the layer its figure is two sprites
|
||
on a black field. The tag makes `docs_screenshots.sh` hand the tool
|
||
`assets/fonts/C64_Pro_Mono-STYLE.ttf` at `AKBASIC_FRONTEND_FONT_SIZE`, which is what the
|
||
standalone frontend opens, so the figure's cell size is the reader's cell size. The sink is
|
||
then the akgl one *alone* rather than a tee, so the program's output goes into the picture
|
||
instead of onto stdout — which the caller reads to decide a figure failed. A raised error
|
||
is still caught, by `docs_examples` running the same block.
|
||
|
||
Three rules around it:
|
||
|
||
- **The PNGs are checked in.** A reader on the forge has no build tree. They are generated
|
||
files that are tracked on purpose, so **do not hand-edit one** — change the listing and
|
||
regenerate, which is the whole point of the arrangement.
|
||
- **The build never regenerates them.** The target is only ever run deliberately, because a
|
||
`make` that quietly rewrote eight binaries would put that diff in front of whoever
|
||
happened to build.
|
||
- **`docs_examples` fails a tagged block with no image**, in both configurations, so a
|
||
figure cannot be added to a chapter and then forgotten.
|
||
|
||
`requires=akgl` belongs on a screenshot block too. The two tags answer different questions:
|
||
one is whether the program is *run by the suite*, the other is whether it *produces a
|
||
figure*, and a graphics listing wants both.
|
||
|
||
`text` exists because `docs/14-architecture.md` needed block diagrams and an untagged block
|
||
is a hard error. It carries no executable claim, which is exactly why it has to be *said*:
|
||
the alternative was an indented code block that the extractor never sees, and a picture
|
||
nobody decided about is indistinguishable from a test nobody ran. Keep the diagrams inside
|
||
about 88 columns so they do not wrap on a forge.
|
||
|
||
### Which one to reach for
|
||
|
||
- **A program with visible output** — `basic` plus an `output` block. Preferred: it is the
|
||
only shape that pins what the reader will actually see.
|
||
- **A program with no output** — `basic` alone. The harness still asserts it parses, runs,
|
||
and raises nothing.
|
||
- **An interactive transcript** — `basic repl`, with the input in one block and the output
|
||
in the next. A block that interleaves the two cannot be checked mechanically, and
|
||
splitting it is not a loss: the piped interpreter prints `READY` once at startup rather
|
||
than after each line, so an interleaved transcript is not literally true anyway.
|
||
- **A verb that draws, plays or moves** — `basic requires=akgl`. These produce no stdout,
|
||
so they get no `output` block; the assertion is that they do not refuse.
|
||
- **A refusal message** — write the program that provokes it and put the message in an
|
||
`output` block. Refusals carry a trailing blank line; the block has to have it too.
|
||
- **`norun`** — for a fragment (`60 DATA 255, 129, ...`), an infinite loop, or a command
|
||
that would reconfigure the tree the suite is running inside. Say why in the prose.
|
||
- **A diagram or a captured trace** — `text`. Nothing is checked, so anything in one that
|
||
*could* be checked belongs in a block that is.
|
||
|
||
### `sh` blocks: what is not run, and why
|
||
|
||
Fourteen of the sixteen shell blocks are `norun`, and the reason is the same for all of
|
||
them: they operate on the build tree the test is running inside, or they need the network.
|
||
Only `docs/02-getting-started.md` runs any, and it runs them in the sandbox.
|
||
|
||
| Block | Why |
|
||
|---|---|
|
||
| `git submodule update --init --recursive` | Network, and the sandbox is not a repository |
|
||
| `cmake -S . -B build` | Would reconfigure the live build tree mid-test |
|
||
| `ctest --test-dir build` | Re-enters this suite |
|
||
| `doxygen Doxyfile` | Slow, and writes into the build tree |
|
||
| the `mutation` target | Hours |
|
||
|
||
That leaves a real gap: a renamed CMake option in `README.md` would go unnoticed. It could
|
||
be closed later with an `excerpt`-style check against `CMakeLists.txt`. It has not been.
|
||
|
||
### Preludes
|
||
|
||
`tests/docs_preludes/NAME.pre` and `NAME.post` bracket a `c wrap=NAME` block. They exist so
|
||
an example can be written the way a reader wants to read it — `PASS(e, akbasic_runtime_global(...))`
|
||
and nothing else — while still compiling. What belongs in a prelude:
|
||
|
||
- Symbols the example **invents** to stay readable: `your_clock_ms()`, `my_renderer`.
|
||
- Scaffolding the macro protocol requires: a function body, a `PREPARE_ERROR`, an `ATTEMPT`
|
||
for a block of `CATCH` calls.
|
||
- Includes the surrounding prose already listed and the block does not repeat.
|
||
|
||
What does **not** belong in a prelude is anything that would let a wrong example compile.
|
||
A prelude declaring `akbasic_runtime_init` itself, for instance, would defeat the check.
|
||
|
||
Compiler diagnostics point at the markdown: the harness emits a `#line` directive, so a
|
||
broken example reports as `README.md:322: error: too few arguments`.
|
||
|
||
### Excerpts
|
||
|
||
`c excerpt=include/akbasic/sink.h` says the block is a copy of something in that header and
|
||
must still match it, comments and whitespace ignored. Use it where compiling the block
|
||
would be wrong — echoing a `typedef` compiles only by redefining the type. This is the
|
||
check that caught the stale `akbasic_TextSink`, and a compile check could not have.
|
||
|
||
### When `docs_examples` fails
|
||
|
||
The message names the file and the line of the block. For an output mismatch it prints both
|
||
sides through `cat -A`, because the errors it catches are trailing spaces and missing
|
||
newlines, which a plain diff renders invisibly.
|
||
|
||
**Fix the documentation, not the expectation** — unless the interpreter is what changed, in
|
||
which case fix the interpreter first and the documentation second. Never edit an `output`
|
||
block to match output you have not looked at.
|
||
|
||
Run one document at a time while you work:
|
||
|
||
```sh norun
|
||
./tests/docs_examples.sh --root . --basic ./build/basic \
|
||
--cflags-file build/docs_cflags.txt docs/04-control-flow.md
|
||
```
|
||
|
||
`--root` and `--basic` are made absolute by the script before it starts, because both are
|
||
used from inside a sandbox directory it `cd`s into. Relative ones used to fail every
|
||
example with `exited 127` and every `setup=` with "setup failed" — invisible under CTest,
|
||
which passes absolute paths, and immediate for anyone running one document by hand.
|
||
|
||
The pass line reports **what it executed, by kind**. Read it. A harness that passes because
|
||
it stopped matching anything looks exactly like a harness that passes because the
|
||
documentation is correct, and the count is the only thing that tells them apart — it has
|
||
already caught one such case, where a CMake generator expression evaluated to an empty
|
||
argument that the script read as a filename.
|