Compile, link and run every example in the documentation

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

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

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

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

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

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

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

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

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

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

View File

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