Files
libakgl/tools/docs_checkjson.c

295 lines
9.8 KiB
C
Raw Normal View History

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>
2026-08-01 20:58:11 -04:00
/**
* @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;
}