diff --git a/CMakeLists.txt b/CMakeLists.txt index cb1060f..15f3324 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -576,6 +576,164 @@ if(AKGL_VENDORED_DEPENDENCIES) 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 . +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 $<$:...>, because a target whose property +# is empty would otherwise contribute a bare `-I` line that swallows the next +# flag. $ and $ would say it more directly and both +# need CMake 3.15; this file declares 3.10 and means it. +set(AKGL_DOCS_CFLAGS "$<$>:-I$,\n-I>\n>") +foreach(_docstarget IN LISTS AKGL_DOCS_DEPENDENCY_TARGETS) + string(APPEND AKGL_DOCS_CFLAGS + "$<$>:-I$,\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 "$\n") + string(APPEND AKGL_DOCS_LDFLAGS "-Wl,-rpath,$\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 $ +) +# 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 # source change at a time, and verifies that the passing tests detect it. The # intentionally failing character test is excluded by the harness. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md new file mode 100644 index 0000000..89830d6 --- /dev/null +++ b/docs/MAINTENANCE.md @@ -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 +`` 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. diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..1f8d350 --- /dev/null +++ b/docs/images/README.md @@ -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. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..4b444df --- /dev/null +++ b/examples/CMakeLists.txt @@ -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() diff --git a/tests/docs_examples.sh b/tests/docs_examples.sh new file mode 100755 index 0000000..47dcac1 --- /dev/null +++ b/tests/docs_examples.sh @@ -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 , 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 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}" diff --git a/tests/docs_preludes/akglapp.post b/tests/docs_preludes/akglapp.post new file mode 100644 index 0000000..9b82a9c --- /dev/null +++ b/tests/docs_preludes/akglapp.post @@ -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; +} diff --git a/tests/docs_preludes/akglapp.pre b/tests/docs_preludes/akglapp.pre new file mode 100644 index 0000000..d3f55a4 --- /dev/null +++ b/tests/docs_preludes/akglapp.pre @@ -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 + +akerr_ErrorContext AKERR_NOIGNORE *docs_example(void); + +akerr_ErrorContext *docs_example(void) +{ + PREPARE_ERROR(errctx); diff --git a/tests/docs_preludes/akglbody.post b/tests/docs_preludes/akglbody.post new file mode 100644 index 0000000..ca8b1b5 --- /dev/null +++ b/tests/docs_preludes/akglbody.post @@ -0,0 +1,2 @@ + SUCCEED_RETURN(errctx); +} diff --git a/tests/docs_preludes/akglbody.pre b/tests/docs_preludes/akglbody.pre new file mode 100644 index 0000000..618ff53 --- /dev/null +++ b/tests/docs_preludes/akglbody.pre @@ -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 + +akerr_ErrorContext AKERR_NOIGNORE *docs_example(void); + +akerr_ErrorContext *docs_example(void) +{ + PREPARE_ERROR(errctx); diff --git a/tests/docs_preludes/akglfile.pre b/tests/docs_preludes/akglfile.pre new file mode 100644 index 0000000..02bbb9a --- /dev/null +++ b/tests/docs_preludes/akglfile.pre @@ -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 diff --git a/tests/docs_preludes/akglframe.post b/tests/docs_preludes/akglframe.post new file mode 100644 index 0000000..ca8b1b5 --- /dev/null +++ b/tests/docs_preludes/akglframe.post @@ -0,0 +1,2 @@ + SUCCEED_RETURN(errctx); +} diff --git a/tests/docs_preludes/akglframe.pre b/tests/docs_preludes/akglframe.pre new file mode 100644 index 0000000..1a08e2e --- /dev/null +++ b/tests/docs_preludes/akglframe.pre @@ -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 + +akerr_ErrorContext *docs_frame(void) +{ + PREPARE_ERROR(errctx); diff --git a/tests/docs_preludes/docs_prelude.h b/tests/docs_preludes/docs_prelude.h new file mode 100644 index 0000000..477461c --- /dev/null +++ b/tests/docs_preludes/docs_prelude.h @@ -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 +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @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_ diff --git a/tests/docs_setups/character.sh b/tests/docs_setups/character.sh new file mode 100755 index 0000000..0c361ad --- /dev/null +++ b/tests/docs_setups/character.sh @@ -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 " >&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 diff --git a/tests/docs_setups/spritesheet.sh b/tests/docs_setups/spritesheet.sh new file mode 100755 index 0000000..5fc8ac0 --- /dev/null +++ b/tests/docs_setups/spritesheet.sh @@ -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 " >&2 + exit 2 +fi + +cp "${ROOT}/tests/assets/spritesheet.png" spritesheet.png || exit 1 diff --git a/tests/docs_setups/tilemap.sh b/tests/docs_setups/tilemap.sh new file mode 100755 index 0000000..7098317 --- /dev/null +++ b/tests/docs_setups/tilemap.sh @@ -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 " >&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 diff --git a/tools/docs_checkjson.c b/tools/docs_checkjson.c new file mode 100644 index 0000000..dadffff --- /dev/null +++ b/tools/docs_checkjson.c @@ -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 +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @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 [ ]...\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; +} diff --git a/tools/docs_screenshot.c b/tools/docs_screenshot.c new file mode 100644 index 0000000..1944787 --- /dev/null +++ b/tools/docs_screenshot.c @@ -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 +#include +#include + +#include +#include + +#include + +#include + +/** @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 [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; +} diff --git a/tools/docs_screenshots.sh b/tools/docs_screenshots.sh new file mode 100755 index 0000000..37df2c0 --- /dev/null +++ b/tools/docs_screenshots.sh @@ -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}"