Compile, link and run every example in the documentation

libakgl exports 157 functions and the only user-facing documentation was a FAQ
in README.md whose examples did not compile: two unbalanced `PASS()` calls, a
`sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9`
inside a bitmask expression, an `int screenwidth = NULL`, and -- in the first
snippet a reader ever saw -- the exact `strncpy` call AGENTS.md forbids.

That is not a reader routing around typos. It is what happens to samples that
nothing executes. This is akbasic's documentation harness with the interpreter
taken out and the ability to link and run put in.

The contract is a set of fence info strings, so an example is ordinary markdown
that still highlights on the forge:

  ```c                  compiled with -fsyntax-only -Wall -Werror
  ```c wrap=NAME        the same, wrapped in tests/docs_preludes/NAME.pre/.post
  ```c run=NAME         linked against akgl and run headless
  ```c excerpt=PATH     must still appear verbatim in PATH
  ```c screenshot=NAME  also the source of docs/images/NAME.png
  ```json kind=KIND     loaded through the real akgl_*_load_json
  ```output             the exact stdout of the runnable block above it

`run=` is why this links at all: -fsyntax-only proves a call typechecks, not
that the startup order works or that an ATTEMPT block gives back what it took.
`json kind=` exists because the asset formats are documented in prose and read
by four loaders with nothing tying the two together -- util/assets/littleguy.json
is already invalid against the loader it ships with.

A fence with no info string is a hard error, and so is an unknown one. The
failure mode this exists to prevent is passing because it quietly ran nothing,
so an unannotated block is a missing decision rather than a default. Exit status
is the number of failed examples; 2 for a usage error, which is a different
thing and has to be distinguishable.

Proven to fail on all ten of its failure modes -- untagged fence, non-compiling
snippet, stale excerpt, orphan output block, unknown info string, run= output
mismatch, invalid JSON, missing figure, a -Werror warning, and a dangling
preload= -- because a check that has never failed has not been tested.

`-Werror` here although AKGL_WERROR stays off for the library: that option is
off so a new compiler's diagnostic cannot break a consumer's build, and a doc
snippet is not a consumer. A sample that warns is a sample that teaches the
warning.

Registered as `docs_examples` and `docs_screenshots`. No docs-path filter in CI,
deliberately: documentation goes stale because the code moved, not because
somebody edited a chapter.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 20:58:11 -04:00
parent 2766372f37
commit 8ac291d2dd
19 changed files with 2271 additions and 0 deletions

View File

@@ -576,6 +576,164 @@ if(AKGL_VENDORED_DEPENDENCIES)
endif() endif()
endif() endif()
# ---------------------------------------------------------------------------
# The documentation's own examples.
#
# docs/ is a manual full of C snippets, asset files and shell commands, and the
# README this project started with proves what happens to samples nothing
# executes: unbalanced PASS() calls, a stray `9` inside a bitmask expression, an
# `int screenwidth = NULL`, and the exact strncpy call AGENTS.md forbids. Two
# prose claims in the headers were false against src/ as well.
#
# Documentation goes stale because the *code* moved, not because somebody edited
# a chapter, so this is an ordinary test case rather than something a docs-only
# CI job runs behind a path filter: it fails when the library changes under the
# chapter, which is exactly the case a path filter would miss.
#
# tests/docs_examples.sh reads the fence info strings; docs/MAINTENANCE.md
# documents them.
# ---------------------------------------------------------------------------
# The snippets compile against the real include path, which is transitive
# through akerror, akstdlib, SDL3, SDL3_image and jansson. Writing it out from
# the target's own INCLUDE_DIRECTORIES keeps one source of truth: a hardcoded -I
# list in the script would rot exactly the way the documentation does.
#
# INTERFACE_INCLUDE_DIRECTORIES of the linked targets as well as libakgl's own,
# because akerror.h and akstdlib.h are part of libakgl's public interface and
# neither lives under include/akgl. akgl's own INCLUDE_DIRECTORIES does not
# carry them -- they arrive at a consumer through target_link_libraries -- so
# taking only that property leaves a snippet unable to find <akerror.h>.
set(AKGL_DOCS_DEPENDENCY_TARGETS
SDL3::SDL3
SDL3_image::SDL3_image
SDL3_mixer::SDL3_mixer
SDL3_ttf::SDL3_ttf
akstdlib::akstdlib
akerror::akerror
jansson::jansson
)
set(AKGL_DOCS_CFLAGS_FILE "${CMAKE_CURRENT_BINARY_DIR}/docs_cflags.txt")
# Each entry is wrapped in $<$<BOOL:...>:...>, because a target whose property
# is empty would otherwise contribute a bare `-I` line that swallows the next
# flag. $<FILTER> and $<REMOVE_DUPLICATES> would say it more directly and both
# need CMake 3.15; this file declares 3.10 and means it.
set(AKGL_DOCS_CFLAGS "$<$<BOOL:$<TARGET_PROPERTY:akgl,INCLUDE_DIRECTORIES>>:-I$<JOIN:$<TARGET_PROPERTY:akgl,INCLUDE_DIRECTORIES>,\n-I>\n>")
foreach(_docstarget IN LISTS AKGL_DOCS_DEPENDENCY_TARGETS)
string(APPEND AKGL_DOCS_CFLAGS
"$<$<BOOL:$<TARGET_PROPERTY:${_docstarget},INTERFACE_INCLUDE_DIRECTORIES>>:-I$<JOIN:$<TARGET_PROPERTY:${_docstarget},INTERFACE_INCLUDE_DIRECTORIES>,\n-I>\n>")
endforeach()
file(GENERATE
OUTPUT "${AKGL_DOCS_CFLAGS_FILE}"
CONTENT "${AKGL_DOCS_CFLAGS}"
)
# The link line for the `c run=` and `c screenshot=` blocks. Full library paths
# plus an rpath entry per directory: a vendored build leaves the SDL satellite
# libraries in per-project subdirectories that are not on the loader's default
# search path, which is the same problem AKGL_VENDORED_RPATH solves for the test
# executables.
set(AKGL_DOCS_LDFLAGS_FILE "${CMAKE_CURRENT_BINARY_DIR}/docs_ldflags.txt")
set(AKGL_DOCS_LDFLAGS "")
foreach(_docstarget akgl ${AKGL_DOCS_DEPENDENCY_TARGETS})
string(APPEND AKGL_DOCS_LDFLAGS "$<TARGET_LINKER_FILE:${_docstarget}>\n")
string(APPEND AKGL_DOCS_LDFLAGS "-Wl,-rpath,$<TARGET_FILE_DIR:${_docstarget}>\n")
endforeach()
string(APPEND AKGL_DOCS_LDFLAGS "-lm\n")
file(GENERATE
OUTPUT "${AKGL_DOCS_LDFLAGS_FILE}"
CONTENT "${AKGL_DOCS_LDFLAGS}"
)
# The json kind= validator: a host that brings libakgl up headless and hands the
# block to the loader that really reads that format. Not a test of its own -- it
# answers a question about a document rather than about the library -- and not
# instrumented, so a documentation run cannot flatter the coverage figure.
add_executable(akgl_docs_checkjson tools/docs_checkjson.c)
target_compile_options(akgl_docs_checkjson PRIVATE ${AKGL_WARNING_FLAGS})
target_link_libraries(akgl_docs_checkjson
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 jansson::jansson -lm)
if(AKGL_VENDORED_DEPENDENCIES)
set_target_properties(akgl_docs_checkjson PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
endif()
# tools/docs_screenshot.c is deliberately not a target here. It supplies main()
# for a program whose other translation unit is a block of markdown, so it is
# compiled by tools/docs_screenshots.sh together with the listing it is hosting;
# there is nothing for `all` to build.
add_test(
NAME docs_examples
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/docs_examples.sh
--root "${CMAKE_CURRENT_SOURCE_DIR}"
--cflags-file "${AKGL_DOCS_CFLAGS_FILE}"
--ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}"
--checkjson $<TARGET_FILE:akgl_docs_checkjson>
)
# Every argument is spelled out rather than assembled from a generator
# expression: an expression that evaluates to nothing still contributes an
# *empty argument*, which the script reads as the first filename. akbasic's
# suite passed having checked no documents at all that way, and was caught only
# because it reports what it ran.
set_tests_properties(docs_examples PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
TIMEOUT 900
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
)
# Every figure in docs/ re-rendered and byte-compared against the tracked copy.
# docs_examples only asks whether the file *exists*, which catches a figure that
# was never generated and not one that stopped being of the code beside it --
# and that second failure is the one this whole arrangement is against.
#
# **A byte comparison of a rendered PNG is a deliberate bet**: that the dummy
# video driver and the software renderer are reproducible run to run and build to
# build. What is untested is an SDL upgrade that shifts one pixel of a diagonal,
# and the answer to that is to regenerate the figures in the same commit as the
# bump -- not to weaken this to a size check, which would pass for every wrong
# picture that happened to be 320x240.
add_test(
NAME docs_screenshots
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/docs_screenshots.sh
--root "${CMAKE_CURRENT_SOURCE_DIR}"
--cflags-file "${AKGL_DOCS_CFLAGS_FILE}"
--ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}"
--check
)
set_tests_properties(docs_screenshots PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
TIMEOUT 900
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
)
# Regenerating the figures is a deliberate act, never part of a build: the PNGs
# are tracked, and a rebuild that silently rewrote them would put a binary diff
# in front of anybody who happened to run `make`. Same contract as the
# controllerdb target above, and for the same reason.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKGL_DOCS_SCREENSHOTS_TARGET docs_screenshots)
else()
set(AKGL_DOCS_SCREENSHOTS_TARGET akgl_docs_screenshots)
endif()
add_custom_target(${AKGL_DOCS_SCREENSHOTS_TARGET}
COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/tools/docs_screenshots.sh"
--root "${CMAKE_CURRENT_SOURCE_DIR}"
--cflags-file "${AKGL_DOCS_CFLAGS_FILE}"
--ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}"
DEPENDS akgl
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Regenerating the documentation figures in docs/images"
VERBATIM
)
# The two tutorial games. Guarded because they are written by a later pass and
# an absent directory must not break the configure -- and guarded again inside
# examples/CMakeLists.txt, one game at a time, for the same reason.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt")
add_subdirectory(examples)
endif()
# Mutation testing copies the repository to scratch space, applies one small # Mutation testing copies the repository to scratch space, applies one small
# source change at a time, and verifies that the passing tests detect it. The # source change at a time, and verifies that the passing tests detect it. The
# intentionally failing character test is excluded by the harness. # intentionally failing character test is excluded by the harness.

255
docs/MAINTENANCE.md Normal file
View File

@@ -0,0 +1,255 @@
# Maintaining the manual
This file documents the harness that keeps `docs/` honest. It is not a chapter —
a reader learning libakgl never needs it — and it is checked by the same harness
it describes, so the file that publishes the convention is held to it.
## Documentation examples
**Every fenced block in `docs/*.md` is compiled, linked, run, or matched against
a source file by `ctest`.** The test is `docs_examples`; it runs in an ordinary
build and it fails the build.
It exists because the documentation this manual replaced had not been executed
since the day it was typed. In four hundred lines of `README.md`: two `PASS()`
calls with unbalanced parentheses, a `sprite->frameids = [0, 1, 2, 3];` that is
not C in any dialect, a stray `9` inside a bitmask expression, an
`int screenwidth = NULL`, and — in the first snippet a reader ever saw — the
exact `strncpy` call `AGENTS.md` forbids under **Copying Into Fixed-Width
Fields**. Two prose claims in the headers were false against `src/` as well.
That is not a reader routing around typos; it is what happens to samples nothing
runs.
Documentation goes stale because the **code** moved, not because somebody edited
a chapter. So this is an ordinary test case rather than something a docs-only CI
job runs behind a path filter — it fails when the library changes under the
chapter, which is precisely the case a path filter would miss.
**An untagged block is a failure**, not a default, and so is a tag this harness
does not know. That is deliberate: the way a harness like this dies is by
quietly matching nothing and passing, so an untagged block is a missing decision
rather than a free pass.
### The tags
| Info string | What happens |
|---|---|
| `c` | A whole translation unit. Compiled with `-fsyntax-only -std=gnu99 -Wall -Werror` against the real include path. It shows its own `#include` lines |
| `c wrap=NAME` | The same, with `tests/docs_preludes/NAME.pre` before it and `NAME.post` after |
| `c run=NAME` | Wrapped in `NAME`, **linked against `akgl` and run headless**. Must exit 0 and must not report an unhandled or ignored error |
| `c excerpt=PATH` | Must still appear in `PATH`, ignoring comments and whitespace. **Not compiled** |
| `c screenshot=NAME` | Compiled as an `akglframe` body, and the source of `docs/images/NAME.png`. Optionally `size=WxH` |
| `json kind=KIND` | Written to a file and loaded through libakgl's own loader. `KIND` is `sprite`, `character`, `tilemap` or `properties` |
| `output` | The **exact stdout** of the runnable block above it, compared byte for byte |
| `sh` | Run in a sandbox. Must exit 0. A leading `$ ` is stripped |
| `sh norun` | Shown, not run. Destructive, networked, or it re-enters this suite |
| `cmake` | **Never executed.** Hand-maintained, by decision |
| `text` | **Never executed.** A block diagram, or a stack trace captured from a real run |
| `norun` | Shown, never executed, whatever it is |
These attributes combine with several of the above:
| Attribute | What it does |
|---|---|
| `setup=NAME` | Runs `tests/docs_setups/NAME.sh` in the sandbox first, with the repository root as its only argument. Valid on `c run=`, `json`, `sh` and `c screenshot=` |
| `size=WxH` | A figure's surface size. Only on `c screenshot=`; the default is 320x240 |
| `name=NAME` | Gives a `json` block a name a later block in the same chapter can preload |
| `preload=A,B` | Loads those named `json` blocks, in that order, before this one |
### Which one to reach for
In this order, and a step down the list is a decision that belongs in the prose:
- **`c run=akglapp` with an `output` block below it.** The strongest shape there
is. `-fsyntax-only` proves a call typechecks; it does not prove the startup
order works, that the sprite loaded, or that the `ATTEMPT` block gave back
what it took. A `run` block proves all three, and the `output` block pins what
the reader will actually see.
- **`c run=akglapp` alone**, where the example has no output. The harness still
asserts a clean exit and that nothing raised.
- **`c wrap=akglbody`**, for a call sequence whose effect is not visible on
stdout.
- **`c`**, for a translation unit the chapter wants shown whole — a header, a
backend definition, a `main`.
- **`c excerpt=PATH`**, for a constant table, a `typedef` or a declaration.
Compiling one of those would only redefine the type; the question worth asking
is whether it still says what the header says.
- **`json kind=`**, for any asset file.
- **`norun`**, for a fragment or something that cannot be run here. Say why in
the prose. **A rising `norun` count is a review finding** — it is the harness
being talked out of its job.
### Preludes
`tests/docs_preludes/NAME.pre` and `NAME.post` bracket the block. They exist so
an example can be written the way a reader wants to read it while still
compiling — four lines of `PREPARE_ERROR` scaffolding repeated in twenty
chapters teaches the protocol twenty times and the call once.
| Prelude | What the block supplies |
|---|---|
| `akglfile` | File scope: declarations, a `movementlogicfunc`, a backend vtable. Includes only; there is no `.post` |
| `akglbody` | The inside of a function returning an error context, with `errctx` already prepared |
| `akglapp` | The same body, plus a `main` that brings libakgl up headless and runs it. This is what `run=` uses |
| `akglframe` | One frame of drawing, for a figure. Linked against `tools/docs_screenshot.c` |
All four begin with `tests/docs_preludes/docs_prelude.h`, which is the include
set — every public header, since the `headers` suite already proves each one is
self-contained. A block with no `wrap=` gets none of it and shows its own
includes, which is usually what a chapter wants a reader to see.
What does **not** belong in a prelude is anything that would let a wrong example
compile. A prelude declaring `akgl_game_init` itself would defeat the entire
check.
Compiler diagnostics point at the markdown: the harness emits a `#line`
directive before each body, so a broken example reports as
`docs/14-physics.md:112: error: too few arguments`, not as a line in a scratch
file nobody can open.
### Why `-Werror` here and not on the library
`AGENTS.md` keeps `AKGL_WERROR` **off** by default, and for a good reason:
libakgl is consumed with `add_subdirectory()``akbasic` does exactly that — so
a target-level `-Werror` turns every diagnostic a newer compiler invents into a
broken build for somebody else's project.
A documentation snippet is not a consumer. A sample that warns is a sample that
teaches the warning, and it is the maintainer of this repository who sees the
failure. So the snippets get `-Werror` and the library does not.
`gnu99` rather than `c99`, because `akerror.h` uses `PATH_MAX`, which
`<limits.h>` hides under `__STRICT_ANSI__`. A stricter dialect would fail on the
dependency rather than on the example.
### Asset formats
A `json kind=` block is written to a file and handed to `akgl_docs_checkjson`,
which brings libakgl up headless and calls the loader that really reads that
format — `akgl_sprite_load_json`, `akgl_character_load_json`,
`akgl_tilemap_load` or `akgl_registry_load_properties`.
This exists because the formats are described in prose here and parsed in
`src/`, with nothing tying the two together. That gap is not hypothetical:
`util/assets/littleguy.json` ships beside the loader it is invalid against.
The loaders really open the images and tilesets a block names, so a block that
references one needs a `setup=`. Adding a format is a row in the `KINDS` table
in `tools/docs_checkjson.c`, not a branch.
**Three of the four formats are not self-contained.** A character names sprites
that have to be in `AKGL_REGISTRY_SPRITE` already; a tilemap names characters,
and really opens its tileset image. There are two ways to satisfy that, and
which one to use depends on whether the dependency is something the reader
should see.
**Show it, with `name=` and `preload=`.** A `json kind=sprite name=herostand`
block earlier in a chapter and a `json kind=character preload=herostand` block
later in the same one are loaded into a single process, in that order. The
dependency ends up in front of the reader — which is right when the chapter is
teaching how the two files relate. Names are per-document, so one chapter cannot
reach into another and make the order documents are checked in load-bearing.
**Hide it, with `setup=`.** When the chapter is about one file and the others
would be noise, the fixtures go in a setup script and the `prepare` column of
the `KINDS` table in `tools/docs_checkjson.c` loads them:
| Setup | What it stages | What loads it |
|---|---|---|
| `setup=character` | `./sprites/*.json` plus a sheet, named `hero standing left` and `hero walking left` | the `character` row's `prepare` |
| `setup=tilemap` | `./sprites/*.json`, `./characters/testcharacter.json`, and `assets/tileset.png` | the `tilemap` row's `prepare` |
Those names are a contract between the setup script and the chapter using it.
Change one and change the other, in the same commit. `assets/tileset.png` is
deliberately not the RPG-Maker-named image `tests/assets/testmap.tmj` uses — see
the comment in `tests/docs_setups/tilemap.sh`.
Two things about map fixtures, both confirmed against `src/tilemap.c` rather
than against the prose:
- **Tilesets must be embedded.** `"source"` appears nowhere in the loader;
`akgl_tilemap_load_tilesets_each` reads `columns`, `firstgid`, `tilecount` and
`image` straight off each element of the map's own `tilesets` array. The
external reference Tiled writes by default does not load.
- **Every object in an object group needs a `type` string**, plain rectangles
included. A missing one fails the whole load.
### Figures are output, not assets
A chapter about drawing wants a picture of what the drawing calls draw, and the
way a picture goes wrong is that it stops being of the code beside it —
silently, because a hand-taken screenshot still looks like a screenshot long
after the call changed. So a figure is generated from the listing it
illustrates:
```sh norun
cmake --build build --target docs_screenshots
```
`tools/docs_screenshot.c` is a small second host: the dummy video driver, a
software renderer, one call to the block's `docs_frame()`, read the target back,
write a PNG. `tools/docs_screenshots.sh` compiles the listing against it.
Three rules, and `docs/images/README.md` carries the long version:
- **The PNGs are tracked.** A reader on the forge has no build tree. They are
generated files tracked on purpose, so **do not hand-edit one** — change the
listing and regenerate.
- **The build never regenerates them.** The target is only ever run
deliberately.
- **`docs_examples` fails a tagged block with no image**, so a figure cannot be
added to a chapter and then forgotten. Whether the image is *of that listing*
is the separate `docs_screenshots` test's question: it re-renders every figure
into a scratch directory and compares byte for byte, and it never repairs what
it finds.
### When `docs_examples` fails
The message names the file and the line of the block's **opening fence**. 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 library is what
changed, in which case fix the library 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 . \
--cflags-file build/docs_cflags.txt \
--ldflags-file build/docs_ldflags.txt \
--checkjson build/akgl_docs_checkjson \
docs/14-physics.md
```
Every path is made absolute by the script before it starts, because it `cd`s
into sandbox directories. A relative one used to fail every example and every
`setup=` — invisible under CTest, which passes absolute paths, and immediate for
anyone running one document by hand.
Omit a flag and the blocks that need it are **skipped and counted**, not
silently passed, so a partial invocation cannot look like a clean run.
**Exit status is the number of failed examples**, the house convention; `2` is a
usage or setup error, which is a different thing and has to be distinguishable.
A document named on the command line that cannot be read is `2` — never a
silent fall back to the default list, which is how a suite ends up passing
having checked nothing.
### Read the census line
The last two lines of a run report **what it executed, by kind**:
```text
ran: 41 c snippets, 6 linked programs, 6 output comparisons, 18 excerpts, 4 json blocks, 2 shell blocks, 3 figures
skipped: 5 norun, 2 cmake, 7 text, 0 c (no compiler flags given), 0 run (no linker flags given), 0 json (no validator given)
```
That count is part of the result, not decoration. A harness that passes because
a chapter stopped matching the extractor looks exactly like a harness that
passes because the documentation is correct, and the census is the only thing
that tells them apart. In `akbasic` it caught exactly that: a CMake generator
expression evaluated to an empty argument, the script read it as a filename, and
the suite passed having checked no documents at all.

40
docs/images/README.md Normal file
View File

@@ -0,0 +1,40 @@
# The figures in this directory are generated, and are tracked deliberately
Every `.png` beside this file is **output**, not art. Each one is rendered by
`tools/docs_screenshots.sh` from a ```` ```c screenshot=NAME ```` block in a
chapter — the same code the reader is looking at, compiled against
`tools/docs_screenshot.c`, run headless, and read back off the render target.
They are committed to the repository **on purpose**: a reader looking at the
manual on the forge has no build tree, and a chapter whose figures only exist
after `cmake --build` renders as a page of broken images. This is the same
tracked-generated-artifact contract `AGENTS.md` spells out for
`include/akgl/SDL_GameControllerDB.h`. Do not delete them, do not add them to
`.gitignore`, and do not "clean up" the fact that generated files are tracked.
Working rules:
- **Never hand-edit one, and never replace one with a hand-taken screenshot.**
The whole point of the arrangement is that the picture follows the code. A
figure that was retouched is a figure that has stopped being of the listing
beside it, silently — which is exactly the failure this replaced.
- **An ordinary build does not touch them.** Regeneration is
`cmake --build build --target docs_screenshots`, and nothing else runs the
script. A build that quietly rewrote eight binaries would put that diff in
front of whoever happened to run `make`.
- **Regenerate as a deliberate change**, in the same commit as whatever moved
the picture, so the binary diff is attributable.
- **`ctest -R docs_screenshots` re-renders every figure and compares it byte for
byte**, into a scratch directory. It never repairs what it finds: a test that
fixes what it is measuring passes the second time for the wrong reason.
- **`ctest -R docs_examples` fails a tagged block with no image here**, so a new
figure cannot be added to a chapter and then forgotten.
A byte comparison of a rendered PNG is a deliberate bet: that the dummy video
driver and the software renderer are reproducible run to run and build to build.
They are. What that does not cover is an SDL upgrade shifting one pixel of a
diagonal — and the answer to that is to regenerate the figures in the same
commit as the bump, not to weaken the check to a size comparison that would pass
for every wrong picture of the right dimensions.
See `docs/MAINTENANCE.md` for the fence tags and the rest of the harness.

16
examples/CMakeLists.txt Normal file
View File

@@ -0,0 +1,16 @@
# The two tutorial games.
#
# Each one is a complete, running program that the matching chapter quotes with
# `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot
# drift from a program that builds, because the excerpt check fails the moment
# the source moves.
#
# Every subdirectory is guarded on its own. The games are written by separate
# passes and land at different times, and a configure that fails because one of
# them is not there yet would block the other. There is nothing clever about the
# guard; it exists so an incomplete tree still builds.
foreach(_example sidescroller jrpg)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt")
add_subdirectory(${_example})
endif()
endforeach()

680
tests/docs_examples.sh Executable file
View File

@@ -0,0 +1,680 @@
#!/bin/bash
#
# Compile, link, run and cross-check every example in docs/.
#
# libakgl exports 156 functions and, before this existed, the only user-facing
# documentation was a FAQ in README.md whose examples did not compile: two
# unbalanced PASS() calls, a `sprite->frameids = [0, 1, 2, 3];` that is not C in
# any dialect, a stray `9` inside a bitmask expression, an `int screenwidth =
# NULL`, and -- in the first snippet a reader ever saw -- the exact strncpy call
# AGENTS.md forbids. The prose had drifted the same way: two claims in the
# headers were false against src/.
#
# That is not a reader routing around typos. It is what happens to samples and
# prose that nothing executes. AGENTS.md already makes the argument in another
# context -- "A test that has not failed has not been tested" -- and the sibling
# akbasic repository solved this exact problem, so this is its harness with the
# interpreter taken out and the ability to link and run put in.
#
# Documentation goes stale because the *code* moved, not because somebody edited
# a chapter, so this runs on every ctest rather than behind a docs path filter.
#
# **Exit status is the number of failed examples**, the house convention. 2 for a
# usage or setup error, which is a different thing and has to be distinguishable.
#
# The contract with the documentation is a set of fence info strings. They are
# ordinary markdown, they render as syntax highlighting on the forge, and they
# sit next to the thing they describe. docs/MAINTENANCE.md is the reference; the
# short version:
#
# ```c a translation unit: compile it with -fsyntax-only
# ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post
# ```c run=NAME wrapped, linked against akgl, and run headless
# ```c excerpt=PATH must appear verbatim in PATH; not compiled
# ```c screenshot=NAME also the source of docs/images/NAME.png
# ```json kind=KIND loaded through the real akgl_*_load_json
# ```output the exact stdout of the runnable block above it
# ```sh run in a sandbox; must exit 0
# ```sh setup=NAME the same, after tests/docs_setups/NAME.sh
# ```sh norun destructive, networked, or re-enters this suite
# ```cmake never executed; hand-maintained, by decision
# ```text a diagram or a captured dump; nothing to execute
# ```norun shown, never executed, whatever it is
#
# A block with **no** info string is a hard error, and so is one whose info
# string this script does not know. That is deliberate: the failure mode the
# whole harness exists to avoid is passing because it quietly ran nothing, so an
# unannotated block is a missing decision rather than a default.
set -u
ROOT=""
CFLAGS_FILE=""
LDFLAGS_FILE=""
CHECKJSON=""
usage()
{
cat >&2 <<'EOF'
usage: docs_examples.sh --root DIR [--cflags-file FILE] [--ldflags-file FILE]
[--checkjson PATH] [FILE...]
--root DIR repository root; documentation paths are relative to it
--cflags-file FILE one compiler flag per line, for the ```c blocks
--ldflags-file FILE one linker argument per line, for the ```c run= blocks
--checkjson PATH the built akgl_docs_checkjson, for the ```json blocks
FILE... which documents to check (default: docs/*.md)
Exit status is the number of failed examples, or 2 for a usage or setup error.
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--root) ROOT="$2"; shift 2 ;;
--cflags-file) CFLAGS_FILE="$2"; shift 2 ;;
--ldflags-file) LDFLAGS_FILE="$2"; shift 2 ;;
--checkjson) CHECKJSON="$2"; shift 2 ;;
--help|-h) usage ;;
--*) echo "unknown option $1" >&2; usage ;;
*) break ;;
esac
done
[ -n "${ROOT}" ] || usage
# Every path is forced absolute before anything else runs, because this script
# cd's into sandbox directories. akbasic learned that the hard way: a relative
# --root could not find tests/docs_setups, which is invisible under CTest -- it
# passes absolute paths -- and immediate for anybody running one document by
# hand.
for _var in CHECKJSON CFLAGS_FILE LDFLAGS_FILE; do
_val="${!_var}"
case "${_val}" in
""|/*) ;;
*) printf -v "${_var}" '%s' "${PWD}/${_val}" ;;
esac
done
unset _var _val
cd "${ROOT}" || exit 2
ROOT="${PWD}"
DOCS=("$@")
if [ ${#DOCS[@]} -eq 0 ]; then
# The default list is a glob and is allowed to match nothing: the chapters
# are written in parallel with this, and a harness that refuses to configure
# until docs/ exists is a harness nobody can land first. A document named on
# the command line is a different case entirely -- see below.
DOCS=()
for _doc in docs/*.md; do
[ -r "${_doc}" ] && DOCS+=("${_doc}")
done
unset _doc
if [ ${#DOCS[@]} -eq 0 ]; then
echo "ran: nothing -- no documents matched docs/*.md"
echo "PASS: there is no documentation to check yet"
exit 0
fi
fi
# Refuse a named document that is not there rather than checking nothing and
# passing. An empty argument is the specific case that got here in akbasic: a
# CMake generator expression evaluating to nothing still contributes an empty
# argument, which looked like a filename and silently replaced the whole list.
for _doc in "${DOCS[@]}"; do
if [ ! -r "${_doc}" ]; then
echo "FAIL: no document at \"${_doc}\"" >&2
exit 2
fi
done
unset _doc
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
# The compiler flags the ```c blocks need. CMake writes them, because the
# include path is transitive through akerror, akstdlib, SDL3 and jansson, and
# hardcoding it here would rot exactly the way the documentation does.
CFLAGS=()
if [ -n "${CFLAGS_FILE}" ] && [ -r "${CFLAGS_FILE}" ]; then
while IFS= read -r _flag; do
[ -n "${_flag}" ] && CFLAGS+=("${_flag}")
done < "${CFLAGS_FILE}"
# The preludes reach their shared include set as <docs_prelude.h>, and the
# unit is compiled from a scratch directory, so the search path has to say
# where that is.
CFLAGS+=("-I${ROOT}/tests/docs_preludes")
fi
LDFLAGS=()
if [ -n "${LDFLAGS_FILE}" ] && [ -r "${LDFLAGS_FILE}" ]; then
while IFS= read -r _flag; do
[ -n "${_flag}" ] && LDFLAGS+=("${_flag}")
done < "${LDFLAGS_FILE}"
fi
unset _flag
CC="${CC:-cc}"
FAILURES=0
declare -A RAN=([c]=0 [run]=0 [output]=0 [excerpt]=0 [json]=0 [sh]=0 [screenshot]=0)
declare -A SKIPPED=([norun]=0 [cmake]=0 [text]=0 [nocc]=0 [nold]=0 [nojson]=0)
# Named `json` blocks, so a later one can name them in `preload=`. Reset per
# document: a name is a fact about one chapter, and letting chapter 13 reach
# into chapter 11 would make the order documents are checked in load-bearing.
declare -A JSON_KIND_OF=()
declare -A JSON_BODY_OF=()
# Every failure names the file and the line of the block's *opening* fence, so
# the message points at the thing to edit rather than at this script.
function fail()
{
local where="$1"; shift
echo "FAIL ${where}: $*" >&2
FAILURES=$((FAILURES + 1))
}
# Show a byte-exact comparison. `cat -A` because the errors this catches are
# trailing spaces and missing newlines, which a plain diff renders invisibly.
function show_diff()
{
local want="$1" got="$2"
echo "--- expected ---" >&2
cat -A "${want}" >&2
echo "--- actual ---" >&2
cat -A "${got}" >&2
}
# ---------------------------------------------------------------- extraction
#
# One pass per document, writing each block's body to ${WORK}/blocks/NNNN and a
# line to ${WORK}/blocks/index. Keeping the bodies in files rather than in shell
# variables means a block containing a NUL, a backslash or an unbalanced quote
# is carried through untouched.
function extract()
{
local doc="$1" out="$2"
awk -v OUT="${out}" -v INDEX="${out}/index" '
function pad(n) { return sprintf("%04d", n) }
/^```/ {
if ( !inblock ) {
inblock = 1
tag = substr($0, 4)
sub(/[ \t]+$/, "", tag)
start = NR
count++
body = OUT "/" pad(count)
printf "" > body
next
}
if ( $0 == "```" ) {
close(body)
printf "%s\t%d\t%s\n", pad(count), start, tag >> INDEX
inblock = 0
next
}
}
inblock { print >> body }
' "${doc}"
}
# ------------------------------------------------------------------- helpers
# The value of attribute $1 in an info string $2, or empty. `wrap=akglbody` and
# `kind=sprite` are both read this way.
function attr()
{
local name="$1" info="$2" word
for word in ${info}; do
case "${word}" in
"${name}"=*) echo "${word#*=}"; return ;;
esac
done
}
# True when a bare word appears in an info string. Used for `norun`.
function has_word()
{
local want="$1" info="$2" word
for word in ${info}; do
[ "${word}" = "${want}" ] && return 0
done
return 1
}
# Strip C comments and collapse whitespace, so an excerpt can be compared to the
# header it came from without either side having to carry the other's doc
# comments or indentation. The bit-flag tables in actor.h and iterator.h are
# hand-aligned on purpose -- scripts/reindent.el goes out of its way not to
# disturb them -- so an excerpt of one has to survive being re-aligned.
function normalise_c()
{
awk '
{
line = $0
out = ""
while ( length(line) > 0 ) {
if ( incomment ) {
i = index(line, "*/")
if ( i == 0 ) { line = ""; break }
incomment = 0
line = substr(line, i + 2)
continue
}
i = index(line, "/*")
j = index(line, "//")
if ( j > 0 && (i == 0 || j < i) ) {
out = out substr(line, 1, j - 1)
line = ""
break
}
if ( i == 0 ) { out = out line; line = ""; break }
out = out substr(line, 1, i - 1)
incomment = 1
line = substr(line, i + 2)
}
printf "%s ", out
}
END { printf "\n" }
' "$1" | tr -s ' \t' ' ' | sed -e 's/^ //' -e 's/ $//'
}
# Run tests/docs_setups/NAME.sh in ${1}, with the repository root as its only
# argument. An example that loads an asset needs that asset; putting the fixture
# in a setup script rather than in the chapter keeps the example the shape a
# reader wants to see.
function run_setup()
{
local sandbox="$1" setup="$2" where="$3"
[ -n "${setup}" ] || return 0
if [ ! -r "${ROOT}/tests/docs_setups/${setup}.sh" ]; then
fail "${where}" "setup=${setup} names no tests/docs_setups/${setup}.sh"
return 1
fi
if ! ( cd "${sandbox}" && bash "${ROOT}/tests/docs_setups/${setup}.sh" "${ROOT}" ) \
>/dev/null 2>&1; then
fail "${where}" "setup=${setup} failed"
return 1
fi
return 0
}
# Assemble the translation unit: prelude, a #line directive naming the markdown,
# the body, and the closing half of the prelude. Writes ${WORK}/unit.c.
#
# The #line is what makes a diagnostic read `docs/14-physics.md:112: error: ...`
# rather than pointing into a scratch file nobody can open.
function build_unit()
{
local where="$1" body="$2" wrap="$3" doc="$4" line="$5"
local unit="${WORK}/unit.c"
: > "${unit}"
if [ -n "${wrap}" ]; then
if [ ! -r "${ROOT}/tests/docs_preludes/${wrap}.pre" ]; then
fail "${where}" "names no tests/docs_preludes/${wrap}.pre"
return 1
fi
cat "${ROOT}/tests/docs_preludes/${wrap}.pre" >> "${unit}"
fi
printf '#line %d "%s"\n' "$((line + 1))" "${doc}" >> "${unit}"
cat "${body}" >> "${unit}"
if [ -n "${wrap}" ] && [ -r "${ROOT}/tests/docs_preludes/${wrap}.post" ]; then
printf '#line 1 "tests/docs_preludes/%s.post"\n' "${wrap}" >> "${unit}"
cat "${ROOT}/tests/docs_preludes/${wrap}.post" >> "${unit}"
fi
return 0
}
# --------------------------------------------------------------- block kinds
# A C snippet, compiled and not linked: what most of these examples are for is
# showing the API, and the API is exactly what -fsyntax-only checks.
#
# -Werror here, unlike the library. AGENTS.md keeps AKGL_WERROR off by default
# because libakgl is consumed with add_subdirectory() and a new compiler's
# diagnostic should not break somebody else's build. A documentation snippet is
# not a consumer, and a sample that warns is a sample that teaches the warning.
#
# gnu99 rather than c99: akerror.h uses PATH_MAX, which <limits.h> hides under
# __STRICT_ANSI__, so a stricter dialect would fail on the dependency rather
# than on the example.
function run_c()
{
local where="$1" body="$2" info="$3" doc="$4" line="$5"
local wrap log="${WORK}/cc.log"
if [ ${#CFLAGS[@]} -eq 0 ]; then
SKIPPED[nocc]=$((SKIPPED[nocc] + 1))
return
fi
wrap="$(attr wrap "${info}")"
build_unit "${where}" "${body}" "${wrap}" "${doc}" "${line}" || return
if "${CC}" -fsyntax-only -std=gnu99 -Wall -Werror \
"${CFLAGS[@]}" "${WORK}/unit.c" > "${log}" 2>&1; then
RAN[c]=$((RAN[c] + 1))
else
fail "${where}" "does not compile"
sed -n '1,25p' "${log}" >&2
fi
}
# A C snippet linked into a real program and run headless.
#
# This is the reason to port the harness at all for a game library. -fsyntax-only
# proves a call typechecks; it does not prove the startup order works, that a
# sprite loads, or that an ATTEMPT block releases what it acquired. A run= block
# asserts a clean exit *and* that nothing reported a raised context on stderr,
# because a libakgl program can fail and still exit 0.
function run_c_run()
{
local where="$1" body="$2" info="$3" doc="$4" line="$5" expected="$6"
local wrap log="${WORK}/cc.log" prog="${WORK}/prog"
local sandbox="${WORK}/run" got="${WORK}/got" errout="${WORK}/stderr" status=0
if [ ${#CFLAGS[@]} -eq 0 ] || [ ${#LDFLAGS[@]} -eq 0 ]; then
SKIPPED[nold]=$((SKIPPED[nold] + 1))
return
fi
wrap="$(attr run "${info}")"
build_unit "${where}" "${body}" "${wrap}" "${doc}" "${line}" || return
rm -f "${prog}"
if ! "${CC}" -std=gnu99 -Wall -Werror "${CFLAGS[@]}" \
-o "${prog}" "${WORK}/unit.c" "${LDFLAGS[@]}" > "${log}" 2>&1; then
fail "${where}" "does not build"
sed -n '1,25p' "${log}" >&2
return
fi
rm -rf "${sandbox}"
mkdir -p "${sandbox}"
run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return
# The same headless forcing scripts/memcheck.sh uses, so the answer does not
# depend on whose desktop it ran on.
( cd "${sandbox}" && \
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
"${prog}" ) > "${got}" 2> "${errout}"
status=$?
RAN[run]=$((RAN[run] + 1))
if [ "${status}" -ne 0 ]; then
fail "${where}" "the example exited ${status}"
sed -n '1,20p' "${got}" "${errout}" >&2
return
fi
# An error that was ignored, or one that reached libakerror's unhandled
# handler, is a failure even where the process still exited 0. Both print a
# recognisable line, and both mean the example did not do what it says.
if grep -qE '\*\* IGNORED ERROR \*\*|Unhandled Error' "${errout}"; then
fail "${where}" "the example raised an error it did not handle"
grep -E '\*\* IGNORED ERROR \*\*|Unhandled Error' "${errout}" >&2
return
fi
# stdout only. libakgl logs registry activity to stderr -- three lines every
# time a sprite is created -- and folding that into the comparison would put
# library chatter into a chapter's expected output.
if [ -n "${expected}" ]; then
RAN[output]=$((RAN[output] + 1))
if ! cmp -s "${expected}" "${got}"; then
fail "${where}" "output does not match the block below it"
show_diff "${expected}" "${got}"
fi
fi
}
# A declaration echoed from a header. Compiling it would redefine the type, so
# the check is that it still says what the header says -- which is the drift
# that actually happens, and the one a compile could not catch.
function run_excerpt()
{
local where="$1" body="$2" path="$3"
if [ ! -r "${path}" ]; then
fail "${where}" "excerpt=${path} names no such file"
return
fi
RAN[excerpt]=$((RAN[excerpt] + 1))
if ! normalise_c "${path}" | grep -qF -- "$(normalise_c "${body}")"; then
fail "${where}" "no longer appears in ${path}"
echo "--- the documentation says ---" >&2
cat "${body}" >&2
echo "--- ${path} has drifted from it; comments and whitespace are ignored ---" >&2
fi
}
# An asset file, run through the loader that really reads it.
#
# The formats are documented in prose and read by src/sprite.c, src/character.c,
# src/tilemap.c and src/registry.c, with nothing tying the two together. That gap
# is not hypothetical: util/assets/littleguy.json is already invalid against the
# loader it ships beside. tools/docs_checkjson.c closes it.
function run_json()
{
local where="$1" body="$2" info="$3"
local kind name preload preloads target
local sandbox="${WORK}/json" log="${WORK}/json.log"
local args=()
kind="$(attr kind "${info}")"
if [ -z "${kind}" ]; then
fail "${where}" "a json block with no kind= (sprite, character, tilemap or properties)"
return
fi
# A named block can be preloaded by a later one. Recorded whether or not the
# validator is available, so omitting --checkjson does not turn a bad
# preload= into a different failure.
name="$(attr name "${info}")"
if [ -n "${name}" ]; then
JSON_KIND_OF["${name}"]="${kind}"
JSON_BODY_OF["${name}"]="${body}"
fi
if [ -z "${CHECKJSON}" ] || [ ! -x "${CHECKJSON}" ]; then
SKIPPED[nojson]=$((SKIPPED[nojson] + 1))
return
fi
rm -rf "${sandbox}"
mkdir -p "${sandbox}"
run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return
# Three of the four formats are not self-contained: a character names sprites
# that have to be in the registry already, and a tilemap names characters.
# `preload=` lists the earlier named blocks that have to be loaded first, in
# order -- which puts the dependency in front of the reader rather than in a
# setup script they cannot see.
preloads="$(attr preload "${info}")"
for preload in ${preloads//,/ }; do
if [ -z "${JSON_KIND_OF[${preload}]:-}" ]; then
fail "${where}" "preload=${preload} names no earlier \`json name=${preload}\` block in this document"
return
fi
cp "${JSON_BODY_OF[${preload}]}" "${sandbox}/${preload}.json"
args+=("${JSON_KIND_OF[${preload}]}" "${preload}.json")
done
target="${name:-docs_example}"
cp "${body}" "${sandbox}/${target}.json"
args+=("${kind}" "${target}.json")
if ( cd "${sandbox}" && \
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
"${CHECKJSON}" "${args[@]}" ) > "${log}" 2>&1; then
RAN[json]=$((RAN[json] + 1))
else
fail "${where}" "is not valid ${kind} JSON as far as the loader is concerned"
sed -n '1,25p' "${log}" >&2
fi
}
# A shell command, in a sandbox.
function run_sh()
{
local where="$1" body="$2" info="$3" expected="$4"
local sandbox="${WORK}/sh" script="${WORK}/block.sh" got="${WORK}/got"
rm -rf "${sandbox}"
mkdir -p "${sandbox}"
run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return
# A leading "$ " is a prompt in a transcript, not part of the command.
sed -e 's/^\$ //' "${body}" > "${script}"
if ! ( cd "${sandbox}" && bash -e "${script}" ) > "${got}" 2>&1; then
fail "${where}" "the command failed"
sed -n '1,20p' "${got}" >&2
return
fi
RAN[sh]=$((RAN[sh] + 1))
if [ -n "${expected}" ]; then
RAN[output]=$((RAN[output] + 1))
if ! cmp -s "${expected}" "${got}"; then
fail "${where}" "output does not match the block below it"
show_diff "${expected}" "${got}"
fi
fi
}
# ------------------------------------------------------------------ the pass
for DOC in "${DOCS[@]}"; do
BLOCKS="${WORK}/blocks"
rm -rf "${BLOCKS}"
mkdir -p "${BLOCKS}"
: > "${BLOCKS}/index"
extract "${DOC}" "${BLOCKS}"
JSON_KIND_OF=()
JSON_BODY_OF=()
# Read the index into arrays first. An `output` block belongs to the block
# before it, so each iteration has to be able to look one ahead.
CLAIMED=0
IDS=(); LINES=(); INFOS=()
while IFS=$'\t' read -r _id _line _info; do
IDS+=("${_id}"); LINES+=("${_line}"); INFOS+=("${_info}")
done < "${BLOCKS}/index"
for ((n = 0; n < ${#IDS[@]}; n++)); do
INFO="${INFOS[n]}"
BODY="${BLOCKS}/${IDS[n]}"
WHERE="${DOC}:${LINES[n]}"
KIND="${INFO%% *}"
# An `output` block belongs to the block above it, which claimed it on
# the way past. One that nothing claimed is compared against nothing at
# all -- indistinguishable from a passing test, and the exact failure
# this harness exists to rule out. A *skipped* block still claims its
# output: `norun` is a decision, not an accident.
if [ "${KIND}" = "output" ]; then
if [ "${CLAIMED}" -eq 0 ]; then
fail "${WHERE}" "an output block with nothing runnable above it"
fi
CLAIMED=0
continue
fi
CLAIMED=0
EXPECTED=""
if [ $((n + 1)) -lt ${#IDS[@]} ] && [ "${INFOS[n + 1]%% *}" = "output" ]; then
case "${KIND}" in
sh)
EXPECTED="${BLOCKS}/${IDS[n + 1]}"
CLAIMED=1
;;
c)
if [ -n "$(attr run "${INFO}")" ]; then
EXPECTED="${BLOCKS}/${IDS[n + 1]}"
CLAIMED=1
fi
;;
esac
fi
if has_word norun "${INFO}"; then
SKIPPED[norun]=$((SKIPPED[norun] + 1))
continue
fi
# A `screenshot=NAME` block is the source of docs/images/NAME.png,
# generated by tools/docs_screenshots.sh. The image is tracked, because a
# reader on the forge has no build tree -- so nothing would otherwise
# notice a figure that was tagged and never generated, and the chapter
# would render a broken image. Whether the file is *of that listing* is
# the docs_screenshots test's question, not this one's.
SHOT="$(attr screenshot "${INFO}")"
if [ -n "${SHOT}" ]; then
if [ -r "docs/images/${SHOT}.png" ]; then
RAN[screenshot]=$((RAN[screenshot] + 1))
else
fail "${WHERE}" "screenshot=${SHOT} has no docs/images/${SHOT}.png; run \`cmake --build build --target docs_screenshots\`"
fi
fi
case "${KIND}" in
c)
EXCERPT="$(attr excerpt "${INFO}")"
if [ -n "${EXCERPT}" ]; then
run_excerpt "${WHERE}" "${BODY}" "${EXCERPT}"
elif [ -n "$(attr run "${INFO}")" ]; then
run_c_run "${WHERE}" "${BODY}" "${INFO}" "${DOC}" "${LINES[n]}" "${EXPECTED}"
elif [ -n "${SHOT}" ]; then
# Compiled here as well as rendered there, so a figure's listing
# is held to the same -Werror as every other snippet even in a
# tree where the figures are not being regenerated. wrap= is
# implied: a frame body is not a translation unit.
run_c "${WHERE}" "${BODY}" "wrap=akglframe" "${DOC}" "${LINES[n]}"
else
run_c "${WHERE}" "${BODY}" "${INFO}" "${DOC}" "${LINES[n]}"
fi
;;
json)
run_json "${WHERE}" "${BODY}" "${INFO}"
;;
sh)
run_sh "${WHERE}" "${BODY}" "${INFO}" "${EXPECTED}"
;;
cmake)
SKIPPED[cmake]=$((SKIPPED[cmake] + 1))
;;
# A block diagram, or a stack trace captured from a real run. There is no
# executable claim in either, so the tag exists to say that out loud
# rather than to let one sit here untagged and unnoticed.
text)
SKIPPED[text]=$((SKIPPED[text] + 1))
;;
norun)
SKIPPED[norun]=$((SKIPPED[norun] + 1))
;;
"")
fail "${WHERE}" "fenced block with no info string; tag it (see docs/MAINTENANCE.md)"
;;
*)
fail "${WHERE}" "unknown info string \"${INFO}\" (see docs/MAINTENANCE.md)"
;;
esac
done
done
# The count is part of the result, not decoration. A harness that passes because
# a chapter stopped matching the extractor looks exactly like a harness that
# passes because the documentation is correct, and this is what tells them apart.
# A rising `norun` is the harness being talked out of its job.
echo "ran: ${RAN[c]} c snippets, ${RAN[run]} linked programs, ${RAN[output]} output comparisons, ${RAN[excerpt]} excerpts, ${RAN[json]} json blocks, ${RAN[sh]} shell blocks, ${RAN[screenshot]} figures"
echo "skipped: ${SKIPPED[norun]} norun, ${SKIPPED[cmake]} cmake, ${SKIPPED[text]} text, ${SKIPPED[nocc]} c (no compiler flags given), ${SKIPPED[nold]} run (no linker flags given), ${SKIPPED[nojson]} json (no validator given)"
if [ "${FAILURES}" -eq 0 ]; then
echo "PASS: every documented example does what the documentation says"
fi
exit "${FAILURES}"

View File

@@ -0,0 +1,67 @@
SUCCEED_RETURN(errctx);
}
/**
* Set in HANDLE_DEFAULT and read after FINISH. Returning from inside a HANDLE
* block leaves before RELEASE_ERROR and leaks the context's pool slot; AGENTS.md
* spells that out, and a documentation example is a bad place to teach it wrong.
*/
static int docs_failed = 0;
int main(void)
{
PREPARE_ERROR(errctx);
/*
* Set here as well as in the environment, so the program answers the same
* whether the harness ran it or a reader did.
*/
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_BREAK(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
FAIL_ZERO_BREAK(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/docs", 320, 240, 0,
&akgl_window, &akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s", SDL_GetError());
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
akgl_camera = &akgl_default_camera;
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = 320.0f;
akgl_camera->h = 240.0f;
CATCH(errctx, docs_example());
} CLEANUP {
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the documented example failed");
docs_failed = 1;
/*
* FINISH_NORETURN rather than FINISH: FINISH expands a
* `return __err_context` that an int-returning function cannot compile,
* even where the branch is unreachable. src/main.c in akbasic and
* tools/docs_screenshot.c do the same and say so.
*/
} FINISH_NORETURN(errctx);
return docs_failed;
}

View File

@@ -0,0 +1,24 @@
/*
* akglapp -- a complete program, brought up headless and run.
*
* This is the prelude behind `c run=akglapp`, and it is the reason the harness
* links at all. -fsyntax-only proves a call typechecks; it does not prove the
* startup order works, that the sprite loads, or that an ATTEMPT block gives
* back what it took. A run= block proves those, and an `output` block below it
* pins what the reader will actually see.
*
* The block supplies the body. Anything it prints on stdout is the example's
* output; libakgl's own registry chatter goes to stderr and is not compared.
*
* The startup here is the same sequence tests/sprite.c and tools/docs_screenshot.c
* use, and it is the sequence chapter 07 documents: error registry, renderer
* backend, SDL, window, bind, heap, registries, camera. A host that owns its own
* window does exactly this rather than calling akgl_game_init().
*/
#include <docs_prelude.h>
akerr_ErrorContext AKERR_NOIGNORE *docs_example(void);
akerr_ErrorContext *docs_example(void)
{
PREPARE_ERROR(errctx);

View File

@@ -0,0 +1,2 @@
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,22 @@
/*
* akglbody -- the inside of a function that returns an error context.
*
* The overwhelmingly common shape of a libakgl example: a few locals, an
* ATTEMPT block, and calls that are checked. Written out in full, every one of
* those blocks would open with the same four lines and close with the same two,
* and a reader would learn the protocol six times instead of the call once.
*
* The block supplies everything between `PREPARE_ERROR` and the closing brace.
* `errctx` is the local error context, per the house parameter names.
*
* Nothing here declares an akgl_ function. A prelude that did would let an
* example calling it with the wrong arguments compile, which is the one thing
* this whole harness exists to catch.
*/
#include <docs_prelude.h>
akerr_ErrorContext AKERR_NOIGNORE *docs_example(void);
akerr_ErrorContext *docs_example(void)
{
PREPARE_ERROR(errctx);

View File

@@ -0,0 +1,11 @@
/*
* akglfile -- file scope.
*
* The block is a run of top-level declarations: a movementlogicfunc, a backend
* vtable, a static table. All this prelude supplies is the include set, so the
* chapter does not have to spend six lines on `#include` before showing the one
* function it is about.
*
* There is no akglfile.post. Nothing has to be closed.
*/
#include <docs_prelude.h>

View File

@@ -0,0 +1,2 @@
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,17 @@
/*
* akglframe -- one frame of drawing, for a figure.
*
* `c screenshot=NAME` blocks are wrapped in this and linked against
* tools/docs_screenshot.c, which owns main(), brings libakgl up against an
* offscreen target of the stated size, calls docs_frame(), reads the target
* back and writes docs/images/NAME.png.
*
* The block draws and returns. It must print nothing: tools/docs_screenshots.sh
* fails a figure whose program wrote to stdout, because an image of a blank
* screen is worse than no image at all.
*/
#include <docs_prelude.h>
akerr_ErrorContext *docs_frame(void)
{
PREPARE_ERROR(errctx);

View File

@@ -0,0 +1,64 @@
/**
* @file docs_prelude.h
* @brief The include set every `c wrap=`, `c run=` and `c screenshot=` block gets.
*
* The four preludes in this directory all begin with this file, so the list of
* headers a wrapped example can rely on lives in exactly one place. It is not
* reachable from a `c` block with no `wrap=`: an unwrapped block is a whole
* translation unit and has to show its own `#include` lines, which is usually
* what a chapter wants a reader to see.
*
* Every public header, deliberately. The `headers` suite already proves each one
* is self-contained, so there is no ordering to get right, and an example that
* calls one subsystem while illustrating another does not have to grow an
* include line that is noise in the chapter.
*
* `docs_frame()` is declared here rather than in tools/docs_screenshot.c so the
* host and the akglframe prelude cannot drift apart on the signature.
*/
#ifndef _AKGL_DOCS_PRELUDE_H_
#define _AKGL_DOCS_PRELUDE_H_
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/assets.h>
#include <akgl/audio.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/iterator.h>
#include <akgl/json_helpers.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/staticstring.h>
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include <akgl/types.h>
#include <akgl/util.h>
/**
* @brief One frame of drawing, supplied by a `c screenshot=NAME` block.
*
* tools/docs_screenshot.c brings libakgl up against an offscreen target, calls
* this, and writes what it drew to `docs/images/NAME.png`. The body is the
* chapter's own listing, wrapped by tests/docs_preludes/akglframe.pre.
*/
akerr_ErrorContext AKERR_NOIGNORE *docs_frame(void);
#endif // _AKGL_DOCS_PRELUDE_H_

68
tests/docs_setups/character.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
#
# Stage the sprites a `json kind=character` block names.
#
# akgl_character_load_json resolves every `sprite` field against
# AKGL_REGISTRY_SPRITE and fails on a name that is not there, so a chapter that
# shows a character file on its own is not runnable on its own. Rather than make
# the chapter print two sprite files it is not about, the sprites live here and
# tools/docs_checkjson.c loads everything in ./sprites through
# akgl_sprite_load_json before it touches the character.
#
# **The two sprite names are a contract with docs/11-characters.md**, which names
# `hero standing left` and `hero walking left` in its `sprite_mappings`. Changing
# either name here breaks that chapter, so change both together.
#
# The sheet is staged inside ./sprites because a sprite file's `spritesheet`
# path is resolved relative to the sprite file's own directory.
#
# $1 is the repository root. The caller runs this with the sandbox as the
# working directory.
set -u
ROOT="${1:-}"
if [ -z "${ROOT}" ]; then
echo "usage: character.sh <repository-root>" >&2
exit 2
fi
mkdir -p sprites || exit 1
cp "${ROOT}/tests/assets/spritesheet.png" sprites/spritesheet.png || exit 1
# 576x384 of 48x48 frames: a 12x8 grid, so frames 0-95 exist. Two frames each,
# which is enough for the animation fields to mean something without making the
# fixture a thing anybody has to study.
cat > sprites/hero_standing_left.json <<'JSON'
{
"spritesheet": {
"filename": "spritesheet.png",
"frame_width": 48,
"frame_height": 48
},
"name": "hero standing left",
"width": 48,
"height": 48,
"speed": 200,
"loop": true,
"loopReverse": false,
"frames": [0]
}
JSON
cat > sprites/hero_walking_left.json <<'JSON'
{
"spritesheet": {
"filename": "spritesheet.png",
"frame_width": 48,
"frame_height": 48
},
"name": "hero walking left",
"width": 48,
"height": 48,
"speed": 100,
"loop": true,
"loopReverse": true,
"frames": [0, 1, 2]
}
JSON

View File

@@ -0,0 +1,24 @@
#!/bin/bash
#
# Stage a spritesheet image in the sandbox, as `spritesheet.png`.
#
# A `json kind=sprite` block names its sheet, and the loader really opens it --
# that is the whole point of running the documented format through the real
# loader. The chapter should not have to show a base64 PNG to get there, so the
# fixture comes from here instead.
#
# 576x384, a 12x8 grid of 48x48 frames. tests/assets/spritesheet.png is the same
# image the sprite suite uses.
#
# $1 is the repository root. The caller runs this with the sandbox as the
# working directory.
set -u
ROOT="${1:-}"
if [ -z "${ROOT}" ]; then
echo "usage: spritesheet.sh <repository-root>" >&2
exit 2
fi
cp "${ROOT}/tests/assets/spritesheet.png" spritesheet.png || exit 1

85
tests/docs_setups/tilemap.sh Executable file
View File

@@ -0,0 +1,85 @@
#!/bin/bash
#
# Stage everything a `json kind=tilemap` block needs around it.
#
# A tilemap is the least self-contained of the four formats. Loading one really
# opens the tileset image, and an actor object really resolves its `character`
# property against AKGL_REGISTRY_CHARACTER -- which in turn needs the character's
# sprites registered. So this stages three things, and
# tools/docs_checkjson.c's `tilemap` row loads ./sprites and then ./characters
# before the map itself.
#
# **The tileset image is staged as `assets/tileset.png`**, and a chapter's map
# has to name that path. It is a copy of the sprite sheet, and that is fine: the
# loader does not check the image against the tileset's declared geometry, it
# only has to be a readable PNG.
#
# The obvious alternative was `assets/World_A1.png`, which is what
# tests/assets/testmap.tmj names. That file carries the default filename of
# RPG Maker's bundled art and ships here with no license file beside it, which
# is a finding this project is recording rather than spreading into the manual.
# A tutorial a reader copies must not inherit an obligation nobody has checked.
#
# **The character is named `testcharacter`**, which is a contract with any
# chapter whose map has an actor object. Change it here and change the chapter.
#
# Embedded tilesets only. `"source"` appears nowhere in src/tilemap.c;
# akgl_tilemap_load_tilesets_each reads `columns`, `firstgid`, `tilecount` and
# `image` straight off each element of the map's own `tilesets` array. An
# external tileset reference -- the form Tiled writes by default -- does not
# load. Every object in an object group also needs a `type` string, plain
# rectangles included.
#
# $1 is the repository root. The caller runs this with the sandbox as the
# working directory.
set -u
ROOT="${1:-}"
if [ -z "${ROOT}" ]; then
echo "usage: tilemap.sh <repository-root>" >&2
exit 2
fi
mkdir -p assets sprites characters || exit 1
cp "${ROOT}/tests/assets/spritesheet.png" assets/tileset.png || exit 1
cp "${ROOT}/tests/assets/spritesheet.png" sprites/spritesheet.png || exit 1
cat > sprites/testactor_standing.json <<'JSON'
{
"spritesheet": {
"filename": "spritesheet.png",
"frame_width": 48,
"frame_height": 48
},
"name": "testactor standing",
"width": 48,
"height": 48,
"speed": 200,
"loop": true,
"loopReverse": false,
"frames": [0]
}
JSON
# AKGL_ACTOR_STATE_ALIVE is 1 << 4 and AKGL_ACTOR_STATE_FACE_DOWN is 1 << 0, so
# this mapping is the state 17 a map's actor object usually carries.
cat > characters/testcharacter.json <<'JSON'
{
"name": "testcharacter",
"speedtime": 100,
"speed_x": 0.20,
"speed_y": 0.20,
"acceleration_x": 0.20,
"acceleration_y": 0.20,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "testactor standing"
}
]
}
JSON

294
tools/docs_checkjson.c Normal file
View File

@@ -0,0 +1,294 @@
/**
* @file docs_checkjson.c
* @brief Load one documented asset file through the loader that really reads it.
*
* libakgl's four asset formats -- sprite, character, tilemap and the property
* configuration -- are described in prose in the manual and parsed by
* `src/sprite.c`, `src/character.c`, `src/tilemap.c` and `src/registry.c`, with
* nothing tying the two together. That gap is not hypothetical:
* `util/assets/littleguy.json` ships beside the loader it is invalid against,
* using pre-prefix state names and a `velocity_x` the current loader does not
* accept.
*
* So a `json kind=sprite` block in a chapter is written to a file and handed
* here, and the documented format is by construction the format the loader
* accepts. `tests/docs_examples.sh` is the caller.
*
* **It takes a list of kind/file pairs and loads them in order**, because three
* of the four formats are not self-contained: a character names sprites that
* have to already be in the registry, and a tilemap names characters. One
* process, several files, in the order the library requires them -- which is
* itself a fact about the format that the chapter has to state, and now states
* by writing it down as `preload=`.
*
* **It is a host**, like `tools/docs_screenshot.c` and the test suites: it
* brings SDL and libakgl up headless itself rather than calling
* `akgl_game_init()`, because the loaders need a renderer and the registries and
* nothing else.
*
* The exit status is 0 when every file loaded and 1 when one did not; 2 is a
* usage error. Whatever the loader said is on stderr, which is what the harness
* shows.
*/
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/character.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/tilemap.h>
/**
* @brief The map a `kind=tilemap` block is loaded into.
*
* File scope because an akgl_Tilemap carries every layer and tileset it owns and
* does not belong on a stack -- the same reason src/game.c keeps
* akgl_default_gamemap where it does.
*/
static akgl_Tilemap MAP;
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main(). */
static int failed = 0;
/**
* @brief akgl_tilemap_load() with the signature the dispatch table wants.
*
* The other three loaders take a filename and nothing else. Rather than give the
* table two shapes and a branch to pick between them, the odd one out gets a
* wrapper.
*/
static akerr_ErrorContext AKERR_NOIGNORE *load_tilemap(char *fname)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_tilemap_load(fname, &MAP));
SUCCEED_RETURN(errctx);
}
/**
* @brief Load every `*.json` in @p dir through @p load, in whatever order the
* filesystem hands them back.
*
* An absent directory is not a failure. A `kind=character` block with no
* `setup=` should fail on the sprite it actually names -- which is the sentence
* a chapter author needs to read -- rather than on a fixture directory they
* never asked for.
*/
static akerr_ErrorContext AKERR_NOIGNORE *load_directory(
const char *dir,
akerr_ErrorContext *(*load)(char *fname))
{
PREPARE_ERROR(errctx);
char **found = NULL;
char path[PATH_MAX];
int count = 0;
int i = 0;
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "Received null directory");
FAIL_ZERO_RETURN(errctx, load, AKERR_NULLPOINTER, "Received null loader");
found = SDL_GlobDirectory(dir, "*.json", 0, &count);
if ( found == NULL ) {
SUCCEED_RETURN(errctx);
}
for ( i = 0; i < count; i++ ) {
snprintf(path, sizeof(path), "%s/%s", dir, found[i]);
ATTEMPT {
CATCH(errctx, load(path));
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
}
SDL_free(found);
SUCCEED_RETURN(errctx);
}
/**
* @brief Put the sprites a `kind=character` block names into the registry.
*
* `akgl_character_load_json` resolves every `sprite` field against
* #AKGL_REGISTRY_SPRITE and fails if one is missing, so a chapter showing a
* character file on its own still needs sprites behind it.
* `tests/docs_setups/character.sh` is what stages them.
*/
static akerr_ErrorContext AKERR_NOIGNORE *prepare_character(void)
{
static bool done = false;
PREPARE_ERROR(errctx);
/*
* Once per process, whether it is reached through the `character` row or
* through prepare_tilemap(). Loading the same sprite file twice registers
* the name twice and leaks the first sprite's pool slot.
*/
if ( done == true ) {
SUCCEED_RETURN(errctx);
}
done = true;
PASS(errctx, load_directory("./sprites", akgl_sprite_load_json));
SUCCEED_RETURN(errctx);
}
/**
* @brief The same, one level further out: a tilemap's actor objects name characters.
*
* `akgl_tilemap_load_layer_object_actor` calls `akgl_actor_set_character`, which
* is a registry lookup and nothing more, so the character has to be loaded --
* and a character needs its sprites, which is why this runs both.
*/
static akerr_ErrorContext AKERR_NOIGNORE *prepare_tilemap(void)
{
static bool done = false;
PREPARE_ERROR(errctx);
if ( done == true ) {
SUCCEED_RETURN(errctx);
}
done = true;
PASS(errctx, prepare_character());
PASS(errctx, load_directory("./characters", akgl_character_load_json));
SUCCEED_RETURN(errctx);
}
/** @brief One documented asset format, what has to exist first, and what reads it. */
typedef struct {
char *kind; /**< The `kind=` value in the fence. */
akerr_ErrorContext *(*prepare)(void); /**< Registry state the format needs, or `NULL`. */
akerr_ErrorContext *(*load)(char *fname); /**< What the library does with it. */
} docs_JsonKind;
/*
* A table rather than an if/else ladder, for the reason AGENTS.md gives about
* backends: a fifth format is a row, not a branch. The names are the vocabulary
* docs/MAINTENANCE.md publishes, so they are also the error message when a
* chapter invents one.
*
* The `prepare` column is where "three of these four formats are not
* self-contained" is written down. Each one guards itself so it runs at most
* once per process, however it is reached.
*/
static docs_JsonKind KINDS[] = {
{ "sprite", NULL, akgl_sprite_load_json },
{ "character", prepare_character, akgl_character_load_json },
{ "tilemap", prepare_tilemap, load_tilemap },
{ "properties", NULL, akgl_registry_load_properties },
{ NULL, NULL, NULL }
};
static void usage(void)
{
int i = 0;
fprintf(stderr, "usage: akgl_docs_checkjson <kind> <file.json> [<kind> <file.json>]...\n"
"\n kind is one of:");
for ( i = 0; KINDS[i].kind != NULL; i++ ) {
fprintf(stderr, " %s", KINDS[i].kind);
}
fprintf(stderr, "\n\nLoads each file through libakgl's own loader for that format, in the\n"
"order given, in one process. A character's sprites and a tilemap's\n"
"characters have to be in the registry before the file that names them.\n");
}
/** @brief Load one file through the loader that owns its format. */
static akerr_ErrorContext AKERR_NOIGNORE *load_one(char *kind, char *fname)
{
PREPARE_ERROR(errctx);
int i = 0;
FAIL_ZERO_RETURN(errctx, kind, AKERR_NULLPOINTER, "Received null kind");
FAIL_ZERO_RETURN(errctx, fname, AKERR_NULLPOINTER, "Received null filename");
for ( i = 0; KINDS[i].kind != NULL; i++ ) {
if ( strcmp(KINDS[i].kind, kind) == 0 ) {
if ( KINDS[i].prepare != NULL ) {
PASS(errctx, KINDS[i].prepare());
}
PASS(errctx, KINDS[i].load(fname));
SUCCEED_RETURN(errctx);
}
}
FAIL_RETURN(errctx, AKERR_KEY, "There is no documented asset format called \"%s\"", kind);
}
/** @brief Everything between SDL being up and the loaders having answered. */
static akerr_ErrorContext AKERR_NOIGNORE *check(int pairs, char **argv)
{
PREPARE_ERROR(errctx);
int i = 0;
FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "Received null argument vector");
PASS(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_RETURN(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
FAIL_ZERO_RETURN(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/docs_checkjson", 320, 240, 0,
&akgl_window, &akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s", SDL_GetError());
PASS(errctx, akgl_render_2d_bind(akgl_renderer));
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
for ( i = 0; i < pairs; i++ ) {
PASS(errctx, load_one(argv[i * 2], argv[(i * 2) + 1]));
}
SUCCEED_RETURN(errctx);
}
int main(int argc, char **argv)
{
PREPARE_ERROR(errctx);
if ( argc < 3 || ((argc - 1) % 2) != 0 ) {
usage();
return 2;
}
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, check((argc - 1) / 2, &argv[1]));
} CLEANUP {
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the documented JSON did not load");
/*
* A flag rather than a `return` here: FINISH ends with RELEASE_ERROR,
* and leaving a HANDLE block early means the context never goes back to
* AKERR_ARRAY_ERROR. AGENTS.md documents that as a process-killing leak,
* and scripts/check_error_protocol.py fails the build over it.
*/
failed = 1;
/*
* FINISH_NORETURN rather than FINISH: FINISH expands a
* `return __err_context` that an int-returning function cannot compile,
* even where the branch is unreachable.
*/
} FINISH_NORETURN(errctx);
return failed;
}

164
tools/docs_screenshot.c Normal file
View File

@@ -0,0 +1,164 @@
/**
* @file docs_screenshot.c
* @brief Bring libakgl up against an offscreen target, call one documented frame, save a PNG.
*
* The drawing, sprite and tilemap chapters describe what a call puts on the
* screen, and a sentence is a poor way to describe a picture. This is what turns
* the listing in a chapter into the image beside it: `tools/docs_screenshots.sh`
* extracts the listing from the markdown, compiles it against this file, and
* runs the result -- so the figure is generated from the exact code the reader
* is looking at rather than from a copy that can drift away from it.
*
* **It is a host**, and deliberately a small one: `akgl_game_init()` opens a
* real window, owns the frame loop and does not stop until the game quits, none
* of which a build step wants. What this does instead is the shortest path to
* pixels -- the dummy video driver, a software renderer, one frame, read the
* target back, write it out. `tests/sprite.c` and `tests/draw.c` already set
* that pattern.
*
* The frame itself is `docs_frame()`, supplied by the chapter's block through
* `tests/docs_preludes/akglframe.pre`. It is declared in `docs_prelude.h` so the
* host and the prelude cannot disagree about the signature.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <docs_prelude.h>
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main(). */
static int failed = 0;
static void usage(void)
{
fprintf(stderr,
"usage: akgl_docs_screenshot <output.png> [width] [height]\n"
"\n"
" Calls docs_frame() against an offscreen renderer of the given size\n"
" (default 320x240) and writes what it drew as a PNG.\n");
}
/**
* @brief Everything between SDL being up and the pixels being on disk.
*
* Split out so the ATTEMPT in main() has one thing to CATCH and the SDL teardown
* has one place to happen.
*/
static akerr_ErrorContext AKERR_NOIGNORE *draw_frame(const char *outpath, int w, int h)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
FAIL_ZERO_RETURN(errctx, outpath, AKERR_NULLPOINTER, "Received null output path");
PASS(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_RETURN(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
FAIL_ZERO_RETURN(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/docs_screenshot", w, h, 0,
&akgl_window, &akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s", SDL_GetError());
PASS(errctx, akgl_render_2d_bind(akgl_renderer));
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
akgl_camera = &akgl_default_camera;
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = (float32_t)w;
akgl_camera->h = (float32_t)h;
/*
* Opaque black, so a figure has a defined background rather than whatever
* the driver left in the buffer. Clearing is the host's prerogative -- the
* library's draw calls put things on the target and do not own what is
* underneath them -- and this is the host doing it.
*/
FAIL_ZERO_RETURN(errctx, SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0, 0, 0, 0xff),
AKGL_ERR_SDL, "%s", SDL_GetError());
FAIL_ZERO_RETURN(errctx, SDL_RenderClear(akgl_renderer->sdl_renderer),
AKGL_ERR_SDL, "%s", SDL_GetError());
PASS(errctx, docs_frame());
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_RETURN(errctx, (shot != NULL), AKGL_ERR_SDL,
"Couldn't read the target back: %s", SDL_GetError());
if ( !IMG_SavePNG(shot, outpath) ) {
SDL_DestroySurface(shot);
FAIL_RETURN(errctx, AKGL_ERR_SDL, "Couldn't write %s: %s", outpath, SDL_GetError());
}
SDL_DestroySurface(shot);
SUCCEED_RETURN(errctx);
}
int main(int argc, char **argv)
{
PREPARE_ERROR(errctx);
int w = 320;
int h = 240;
if ( argc < 2 ) {
usage();
return 2;
}
if ( argc > 2 ) {
w = atoi(argv[2]);
}
if ( argc > 3 ) {
h = atoi(argv[3]);
}
if ( w <= 0 || h <= 0 ) {
usage();
return 2;
}
/*
* Set here rather than left to the environment, so the tool answers the same
* on a developer's desktop as it does in CI. A figure regenerated over a
* real GPU could differ by a pixel of antialiasing and turn every rebuild
* into a binary diff.
*/
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, draw_frame(argv[1], w, h));
} CLEANUP {
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "could not draw the figure");
/*
* A flag rather than a `return`: FINISH ends with RELEASE_ERROR, and
* leaving a HANDLE block early means the context never goes back to
* AKERR_ARRAY_ERROR -- one leaked slot per call, and AGENTS.md documents
* where that ends up.
*/
failed = 1;
/*
* FINISH_NORETURN rather than FINISH: FINISH expands a
* `return __err_context` that an int-returning function cannot compile,
* even where the branch is unreachable.
*/
} FINISH_NORETURN(errctx);
return failed;
}

278
tools/docs_screenshots.sh Executable file
View File

@@ -0,0 +1,278 @@
#!/bin/bash
#
# Regenerate every figure in docs/ from the listing shown beside it.
#
# A chapter that says "akgl_draw_rectangle fills a rectangle" and shows a listing
# wants a picture of what that listing draws, and the way a picture goes wrong is
# that it stops being of the code beside it. Nothing tells you: a screenshot
# taken by hand in 2026 still looks like a screenshot in 2027, long after the
# call it illustrates has changed.
#
# So the figure is not an asset, it is *output*. A block tagged
#
# ```c screenshot=rectangle
#
# is compiled against tools/docs_screenshot.c, run, and written to
# docs/images/rectangle.png, and the chapter shows that file. Regenerate and the
# picture follows the code. The generated PNGs are tracked on purpose -- a reader
# on the forge has no build tree -- and tests/docs_examples.sh fails a tagged
# block with no image, so a new figure cannot be forgotten.
#
# **This is not run by the build.** `cmake --build build --target docs_screenshots`
# is deliberate; see the target's comment in CMakeLists.txt.
#
# Exit status is the number of figures that failed, or 2 for a usage or setup
# error.
set -u
ROOT=""
CFLAGS_FILE=""
LDFLAGS_FILE=""
CHECK=0
FAILURES=0
usage()
{
cat >&2 <<'EOF'
usage: docs_screenshots.sh --root DIR --cflags-file FILE --ldflags-file FILE
[--check] [FILE...]
--root DIR repository root; images are written to DIR/docs/images
--cflags-file FILE one compiler flag per line
--ldflags-file FILE one linker argument per line
--check render to a scratch directory and compare, changing nothing
FILE... which documents to regenerate (default: docs/*.md)
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--root) ROOT="$2"; shift 2 ;;
--cflags-file) CFLAGS_FILE="$2"; shift 2 ;;
--ldflags-file) LDFLAGS_FILE="$2"; shift 2 ;;
--check) CHECK=1; shift ;;
--help|-h) usage ;;
--*) echo "unknown option $1" >&2; usage ;;
*) break ;;
esac
done
[ -n "${ROOT}" ] || usage
[ -n "${CFLAGS_FILE}" ] || usage
[ -n "${LDFLAGS_FILE}" ] || usage
# Absolute before anything else, because the loop below cd's into a sandbox to
# run each figure -- a listing that loads an asset resolves it relative to its
# own directory.
for _var in CFLAGS_FILE LDFLAGS_FILE; do
_val="${!_var}"
case "${_val}" in
/*) ;;
*) printf -v "${_var}" '%s' "${PWD}/${_val}" ;;
esac
done
unset _var _val
[ -r "${CFLAGS_FILE}" ] || { echo "FAIL: no compiler flags at ${CFLAGS_FILE}" >&2; exit 2; }
[ -r "${LDFLAGS_FILE}" ] || { echo "FAIL: no linker flags at ${LDFLAGS_FILE}" >&2; exit 2; }
cd "${ROOT}" || exit 2
ROOT="${PWD}"
HOST="${ROOT}/tools/docs_screenshot.c"
[ -r "${HOST}" ] || { echo "FAIL: no figure host at ${HOST}" >&2; exit 2; }
DOCS=("$@")
if [ ${#DOCS[@]} -eq 0 ]; then
DOCS=()
for _doc in docs/*.md; do
[ -r "${_doc}" ] && DOCS+=("${_doc}")
done
unset _doc
if [ ${#DOCS[@]} -eq 0 ]; then
echo "no documents matched docs/*.md; there are no figures to make"
exit 0
fi
fi
for _doc in "${DOCS[@]}"; do
[ -r "${_doc}" ] || { echo "FAIL: no document at \"${_doc}\"" >&2; exit 2; }
done
unset _doc
CFLAGS=()
while IFS= read -r _flag; do
[ -n "${_flag}" ] && CFLAGS+=("${_flag}")
done < "${CFLAGS_FILE}"
CFLAGS+=("-I${ROOT}/tests/docs_preludes")
LDFLAGS=()
while IFS= read -r _flag; do
[ -n "${_flag}" ] && LDFLAGS+=("${_flag}")
done < "${LDFLAGS_FILE}"
unset _flag
CC="${CC:-cc}"
IMAGES="${ROOT}/docs/images"
mkdir -p "${IMAGES}" || exit 2
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
# --check renders somewhere else entirely and compares, so a run that finds a
# stale figure does not also fix it. A test that repairs what it is measuring
# passes the second time for the wrong reason.
if [ "${CHECK}" -eq 1 ]; then
OUTDIR="${WORK}/rendered"
mkdir -p "${OUTDIR}" || exit 2
else
OUTDIR="${IMAGES}"
fi
# The value of attribute $1 in an info string $2, or empty. Same shape as the
# `attr` in tests/docs_examples.sh, and deliberately so -- the two read the same
# fence tags and a second dialect of them would be a bug waiting to happen.
function attr()
{
local name="$1" info="$2" word
for word in ${info}; do
case "${word}" in
"${name}"=*) echo "${word#*=}"; return 0 ;;
esac
done
echo ""
}
function figure_failed()
{
local where="$1"; shift
echo "FAIL ${where}: $*" >&2
FAILURES=$((FAILURES + 1))
}
# One figure: wrap the listing, compile it against the host, run it, keep the PNG.
function render()
{
local name="$1" body="$2" size="$3" where="$4" setup="$5"
local w=320 h=240 out="${OUTDIR}/${name}.png"
local unit="${WORK}/frame.c" prog="${WORK}/figure" log="${WORK}/cc.log"
local sandbox="${WORK}/sandbox" stdoutlog=""
if [ -n "${size}" ]; then
w="${size%x*}"
h="${size#*x}"
fi
# The #line makes a compiler diagnostic read `docs/09-drawing.md:112: error:`
# rather than pointing into a scratch file. `where` names the opening fence,
# so the body starts on the line after it.
{
cat "${ROOT}/tests/docs_preludes/akglframe.pre"
printf '#line %d "%s"\n' "$(( ${where##*:} + 1 ))" "${where%:*}"
printf '%s' "${body}"
cat "${ROOT}/tests/docs_preludes/akglframe.post"
} > "${unit}"
rm -f "${prog}"
if ! "${CC}" -std=gnu99 -Wall -Werror "${CFLAGS[@]}" \
-o "${prog}" "${unit}" "${HOST}" "${LDFLAGS[@]}" > "${log}" 2>&1; then
figure_failed "${where}" "${name} does not build"
sed -n '1,25p' "${log}" >&2
return
fi
rm -rf "${sandbox}"
mkdir -p "${sandbox}"
# The same setup scripts tests/docs_examples.sh uses, run in the same place
# relative to the program. A listing that loads a spritesheet needs one
# whether it is being compiled or being photographed.
if [ -n "${setup}" ]; then
if [ ! -r "${ROOT}/tests/docs_setups/${setup}.sh" ]; then
figure_failed "${where}" "no setup script ${setup}.sh"
return
fi
if ! ( cd "${sandbox}" && bash "${ROOT}/tests/docs_setups/${setup}.sh" "${ROOT}" ) \
>/dev/null 2>&1; then
figure_failed "${where}" "setup ${setup} failed"
return
fi
fi
# **stdout only.** libakgl logs its registry chatter -- "Actor akgl:actor:1
# initialized" -- to stderr, so merging the two would make every sprite
# figure look like a failing program. stderr is shown when the tool itself
# refuses, which is when it is worth reading.
if ! stdoutlog="$(cd "${sandbox}" && \
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
"${prog}" "${out}" "${w}" "${h}" 2>"${WORK}/figure.err")"; then
figure_failed "${where}" "${name} did not render"
cat "${WORK}/figure.err" >&2
return
fi
# A frame that printed something is a frame that reported a problem, and an
# image of a blank screen is worse than no image at all.
if [ -n "${stdoutlog}" ]; then
figure_failed "${where}" "${name} rendered, but the listing printed:"
echo "${stdoutlog}" >&2
return
fi
if [ "${CHECK}" -eq 1 ]; then
if [ ! -r "${IMAGES}/${name}.png" ]; then
figure_failed "${where}" "docs/images/${name}.png does not exist"
return
fi
if ! cmp -s "${out}" "${IMAGES}/${name}.png"; then
figure_failed "${where}" "docs/images/${name}.png is not what that listing draws"
echo " regenerate with: cmake --build build --target docs_screenshots" >&2
return
fi
echo " ${name}.png matches"
return
fi
echo " ${name}.png (${w}x${h})"
}
for DOC in "${DOCS[@]}"; do
# Walk the file collecting fenced blocks. Only `screenshot=` ones are kept;
# everything else is somebody else's problem, and docs_examples.sh is that
# somebody.
IN_BLOCK=0
INFO=""
BODY=""
START=0
LINE_NUMBER=0
while IFS= read -r LINE; do
LINE_NUMBER=$((LINE_NUMBER + 1))
case "${LINE}" in
'```'*)
if [ "${IN_BLOCK}" -eq 0 ]; then
IN_BLOCK=1
INFO="${LINE#'```'}"
BODY=""
START="${LINE_NUMBER}"
else
IN_BLOCK=0
NAME="$(attr screenshot "${INFO}")"
if [ -n "${NAME}" ]; then
render "${NAME}" "${BODY}" "$(attr size "${INFO}")" \
"${DOC}:${START}" "$(attr setup "${INFO}")"
fi
fi
;;
*)
if [ "${IN_BLOCK}" -eq 1 ]; then
BODY="${BODY}${LINE}"$'\n'
fi
;;
esac
done < "${DOC}"
done
if [ "${FAILURES}" -ne 0 ]; then
echo "${FAILURES} figure(s) failed" >&2
fi
exit "${FAILURES}"