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>
279 lines
8.3 KiB
Bash
Executable File
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}"
|