Files
libakgl/tools/docs_screenshots.sh
Andrew Kesterson 8ac291d2dd 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

279 lines
8.3 KiB
Bash
Executable File

#!/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}"