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:
294
tools/docs_checkjson.c
Normal file
294
tools/docs_checkjson.c
Normal 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
164
tools/docs_screenshot.c
Normal 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
278
tools/docs_screenshots.sh
Executable 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}"
|
||||
Reference in New Issue
Block a user