#!/bin/bash # # Compile, link, run and cross-check every example in docs/. # # libakgl exports 156 functions and, before this existed, 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. The prose had drifted the same way: two claims in the # headers were false against src/. # # That is not a reader routing around typos. It is what happens to samples and # prose that nothing executes. AGENTS.md already makes the argument in another # context -- "A test that has not failed has not been tested" -- and the sibling # akbasic repository solved this exact problem, so this is its harness with the # interpreter taken out and the ability to link and run put in. # # Documentation goes stale because the *code* moved, not because somebody edited # a chapter, so this runs on every ctest rather than behind a docs path filter. # # **Exit status is the number of failed examples**, the house convention. 2 for a # usage or setup error, which is a different thing and has to be distinguishable. # # The contract with the documentation is a set of fence info strings. They are # ordinary markdown, they render as syntax highlighting on the forge, and they # sit next to the thing they describe. docs/MAINTENANCE.md is the reference; the # short version: # # ```c a translation unit: compile it with -fsyntax-only # ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post # ```c run=NAME wrapped, linked against akgl, and run headless # ```c excerpt=PATH must appear verbatim in PATH; not compiled # ```c screenshot=NAME also the source of docs/images/NAME.png # ```json kind=KIND loaded through the real akgl_*_load_json # ```json excerpt=PATH must appear verbatim in PATH; not loaded # ```output the exact stdout of the runnable block above it # ```sh run in a sandbox; must exit 0 # ```sh setup=NAME the same, after tests/docs_setups/NAME.sh # ```sh norun destructive, networked, or re-enters this suite # ```cmake never executed; hand-maintained, by decision # ```text a diagram or a captured dump; nothing to execute # ```norun shown, never executed, whatever it is # # A block with **no** info string is a hard error, and so is one whose info # string this script does not know. That is deliberate: the failure mode the # whole harness exists to avoid is passing because it quietly ran nothing, so an # unannotated block is a missing decision rather than a default. set -u ROOT="" CFLAGS_FILE="" LDFLAGS_FILE="" CHECKJSON="" usage() { cat >&2 <<'EOF' usage: docs_examples.sh --root DIR [--cflags-file FILE] [--ldflags-file FILE] [--checkjson PATH] [FILE...] --root DIR repository root; documentation paths are relative to it --cflags-file FILE one compiler flag per line, for the ```c blocks --ldflags-file FILE one linker argument per line, for the ```c run= blocks --checkjson PATH the built akgl_docs_checkjson, for the ```json blocks FILE... which documents to check (default: docs/*.md) Exit status is the number of failed examples, or 2 for a usage or setup error. 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 ;; --checkjson) CHECKJSON="$2"; shift 2 ;; --help|-h) usage ;; --*) echo "unknown option $1" >&2; usage ;; *) break ;; esac done [ -n "${ROOT}" ] || usage # Every path is forced absolute before anything else runs, because this script # cd's into sandbox directories. akbasic learned that the hard way: a relative # --root could not find tests/docs_setups, which is invisible under CTest -- it # passes absolute paths -- and immediate for anybody running one document by # hand. for _var in CHECKJSON CFLAGS_FILE LDFLAGS_FILE; do _val="${!_var}" case "${_val}" in ""|/*) ;; *) printf -v "${_var}" '%s' "${PWD}/${_val}" ;; esac done unset _var _val cd "${ROOT}" || exit 2 ROOT="${PWD}" DOCS=("$@") if [ ${#DOCS[@]} -eq 0 ]; then # The default list is a glob and is allowed to match nothing: the chapters # are written in parallel with this, and a harness that refuses to configure # until docs/ exists is a harness nobody can land first. A document named on # the command line is a different case entirely -- see below. DOCS=() for _doc in docs/*.md; do [ -r "${_doc}" ] && DOCS+=("${_doc}") done unset _doc if [ ${#DOCS[@]} -eq 0 ]; then echo "ran: nothing -- no documents matched docs/*.md" echo "PASS: there is no documentation to check yet" exit 0 fi fi # Refuse a named document that is not there rather than checking nothing and # passing. An empty argument is the specific case that got here in akbasic: a # CMake generator expression evaluating to nothing still contributes an empty # argument, which looked like a filename and silently replaced the whole list. for _doc in "${DOCS[@]}"; do if [ ! -r "${_doc}" ]; then echo "FAIL: no document at \"${_doc}\"" >&2 exit 2 fi done unset _doc WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT # The compiler flags the ```c blocks need. CMake writes them, because the # include path is transitive through akerror, akstdlib, SDL3 and jansson, and # hardcoding it here would rot exactly the way the documentation does. CFLAGS=() if [ -n "${CFLAGS_FILE}" ] && [ -r "${CFLAGS_FILE}" ]; then while IFS= read -r _flag; do [ -n "${_flag}" ] && CFLAGS+=("${_flag}") done < "${CFLAGS_FILE}" # The preludes reach their shared include set as , and the # unit is compiled from a scratch directory, so the search path has to say # where that is. CFLAGS+=("-I${ROOT}/tests/docs_preludes") fi LDFLAGS=() if [ -n "${LDFLAGS_FILE}" ] && [ -r "${LDFLAGS_FILE}" ]; then while IFS= read -r _flag; do [ -n "${_flag}" ] && LDFLAGS+=("${_flag}") done < "${LDFLAGS_FILE}" fi unset _flag CC="${CC:-cc}" FAILURES=0 declare -A RAN=([c]=0 [run]=0 [output]=0 [excerpt]=0 [json]=0 [sh]=0 [screenshot]=0) declare -A SKIPPED=([norun]=0 [cmake]=0 [text]=0 [nocc]=0 [nold]=0 [nojson]=0) # Named `json` blocks, so a later one can name them in `preload=`. Reset per # document: a name is a fact about one chapter, and letting chapter 13 reach # into chapter 11 would make the order documents are checked in load-bearing. declare -A JSON_KIND_OF=() declare -A JSON_BODY_OF=() # Every failure names the file and the line of the block's *opening* fence, so # the message points at the thing to edit rather than at this script. function fail() { local where="$1"; shift echo "FAIL ${where}: $*" >&2 FAILURES=$((FAILURES + 1)) } # Show a byte-exact comparison. `cat -A` because the errors this catches are # trailing spaces and missing newlines, which a plain diff renders invisibly. function show_diff() { local want="$1" got="$2" echo "--- expected ---" >&2 cat -A "${want}" >&2 echo "--- actual ---" >&2 cat -A "${got}" >&2 } # ---------------------------------------------------------------- extraction # # One pass per document, writing each block's body to ${WORK}/blocks/NNNN and a # line to ${WORK}/blocks/index. Keeping the bodies in files rather than in shell # variables means a block containing a NUL, a backslash or an unbalanced quote # is carried through untouched. function extract() { local doc="$1" out="$2" awk -v OUT="${out}" -v INDEX="${out}/index" ' function pad(n) { return sprintf("%04d", n) } /^```/ { if ( !inblock ) { inblock = 1 tag = substr($0, 4) sub(/[ \t]+$/, "", tag) start = NR count++ body = OUT "/" pad(count) printf "" > body next } if ( $0 == "```" ) { close(body) printf "%s\t%d\t%s\n", pad(count), start, tag >> INDEX inblock = 0 next } } inblock { print >> body } ' "${doc}" } # ------------------------------------------------------------------- helpers # The value of attribute $1 in an info string $2, or empty. `wrap=akglbody` and # `kind=sprite` are both read this way. function attr() { local name="$1" info="$2" word for word in ${info}; do case "${word}" in "${name}"=*) echo "${word#*=}"; return ;; esac done } # True when a bare word appears in an info string. Used for `norun`. function has_word() { local want="$1" info="$2" word for word in ${info}; do [ "${word}" = "${want}" ] && return 0 done return 1 } # Strip C comments and collapse whitespace, so an excerpt can be compared to the # header it came from without either side having to carry the other's doc # comments or indentation. The bit-flag tables in actor.h and iterator.h are # hand-aligned on purpose -- scripts/reindent.el goes out of its way not to # disturb them -- so an excerpt of one has to survive being re-aligned. function normalise_c() { awk ' { line = $0 out = "" while ( length(line) > 0 ) { if ( incomment ) { i = index(line, "*/") if ( i == 0 ) { line = ""; break } incomment = 0 line = substr(line, i + 2) continue } i = index(line, "/*") j = index(line, "//") if ( j > 0 && (i == 0 || j < i) ) { out = out substr(line, 1, j - 1) line = "" break } if ( i == 0 ) { out = out line; line = ""; break } out = out substr(line, 1, i - 1) incomment = 1 line = substr(line, i + 2) } printf "%s ", out } END { printf "\n" } ' "$1" | tr -s ' \t' ' ' | sed -e 's/^ //' -e 's/ $//' } # Run tests/docs_setups/NAME.sh in ${1}, with the repository root as its only # argument. An example that loads an asset needs that asset; putting the fixture # in a setup script rather than in the chapter keeps the example the shape a # reader wants to see. function run_setup() { local sandbox="$1" setup="$2" where="$3" [ -n "${setup}" ] || return 0 if [ ! -r "${ROOT}/tests/docs_setups/${setup}.sh" ]; then fail "${where}" "setup=${setup} names no tests/docs_setups/${setup}.sh" return 1 fi if ! ( cd "${sandbox}" && bash "${ROOT}/tests/docs_setups/${setup}.sh" "${ROOT}" ) \ >/dev/null 2>&1; then fail "${where}" "setup=${setup} failed" return 1 fi return 0 } # Assemble the translation unit: prelude, a #line directive naming the markdown, # the body, and the closing half of the prelude. Writes ${WORK}/unit.c. # # The #line is what makes a diagnostic read `docs/14-physics.md:112: error: ...` # rather than pointing into a scratch file nobody can open. function build_unit() { local where="$1" body="$2" wrap="$3" doc="$4" line="$5" local unit="${WORK}/unit.c" : > "${unit}" if [ -n "${wrap}" ]; then if [ ! -r "${ROOT}/tests/docs_preludes/${wrap}.pre" ]; then fail "${where}" "names no tests/docs_preludes/${wrap}.pre" return 1 fi cat "${ROOT}/tests/docs_preludes/${wrap}.pre" >> "${unit}" fi printf '#line %d "%s"\n' "$((line + 1))" "${doc}" >> "${unit}" cat "${body}" >> "${unit}" if [ -n "${wrap}" ] && [ -r "${ROOT}/tests/docs_preludes/${wrap}.post" ]; then printf '#line 1 "tests/docs_preludes/%s.post"\n' "${wrap}" >> "${unit}" cat "${ROOT}/tests/docs_preludes/${wrap}.post" >> "${unit}" fi return 0 } # --------------------------------------------------------------- block kinds # A C snippet, compiled and not linked: what most of these examples are for is # showing the API, and the API is exactly what -fsyntax-only checks. # # -Werror here, unlike the library. AGENTS.md keeps AKGL_WERROR off by default # because libakgl is consumed with add_subdirectory() and a new compiler's # diagnostic should not break somebody else's build. A documentation snippet is # not a consumer, and a sample that warns is a sample that teaches the warning. # # gnu99 rather than c99: akerror.h uses PATH_MAX, which hides under # __STRICT_ANSI__, so a stricter dialect would fail on the dependency rather # than on the example. function run_c() { local where="$1" body="$2" info="$3" doc="$4" line="$5" local wrap log="${WORK}/cc.log" if [ ${#CFLAGS[@]} -eq 0 ]; then SKIPPED[nocc]=$((SKIPPED[nocc] + 1)) return fi wrap="$(attr wrap "${info}")" build_unit "${where}" "${body}" "${wrap}" "${doc}" "${line}" || return if "${CC}" -fsyntax-only -std=gnu99 -Wall -Werror \ "${CFLAGS[@]}" "${WORK}/unit.c" > "${log}" 2>&1; then RAN[c]=$((RAN[c] + 1)) else fail "${where}" "does not compile" sed -n '1,25p' "${log}" >&2 fi } # A C snippet linked into a real program and run headless. # # This is the reason to port the harness at all for a game library. -fsyntax-only # proves a call typechecks; it does not prove the startup order works, that a # sprite loads, or that an ATTEMPT block releases what it acquired. A run= block # asserts a clean exit *and* that nothing reported a raised context on stderr, # because a libakgl program can fail and still exit 0. function run_c_run() { local where="$1" body="$2" info="$3" doc="$4" line="$5" expected="$6" local wrap log="${WORK}/cc.log" prog="${WORK}/prog" local sandbox="${WORK}/run" got="${WORK}/got" errout="${WORK}/stderr" status=0 if [ ${#CFLAGS[@]} -eq 0 ] || [ ${#LDFLAGS[@]} -eq 0 ]; then SKIPPED[nold]=$((SKIPPED[nold] + 1)) return fi wrap="$(attr run "${info}")" build_unit "${where}" "${body}" "${wrap}" "${doc}" "${line}" || return rm -f "${prog}" if ! "${CC}" -std=gnu99 -Wall -Werror "${CFLAGS[@]}" \ -o "${prog}" "${WORK}/unit.c" "${LDFLAGS[@]}" > "${log}" 2>&1; then fail "${where}" "does not build" sed -n '1,25p' "${log}" >&2 return fi rm -rf "${sandbox}" mkdir -p "${sandbox}" run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return # The same headless forcing scripts/memcheck.sh uses, so the answer does not # depend on whose desktop it ran on. ( cd "${sandbox}" && \ SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \ "${prog}" ) > "${got}" 2> "${errout}" status=$? RAN[run]=$((RAN[run] + 1)) if [ "${status}" -ne 0 ]; then fail "${where}" "the example exited ${status}" sed -n '1,20p' "${got}" "${errout}" >&2 return fi # An error that was ignored, or one that reached libakerror's unhandled # handler, is a failure even where the process still exited 0. Both print a # recognisable line, and both mean the example did not do what it says. if grep -qE '\*\* IGNORED ERROR \*\*|Unhandled Error' "${errout}"; then fail "${where}" "the example raised an error it did not handle" grep -E '\*\* IGNORED ERROR \*\*|Unhandled Error' "${errout}" >&2 return fi # stdout only. libakgl logs registry activity to stderr -- three lines every # time a sprite is created -- and folding that into the comparison would put # library chatter into a chapter's expected output. if [ -n "${expected}" ]; then RAN[output]=$((RAN[output] + 1)) if ! cmp -s "${expected}" "${got}"; then fail "${where}" "output does not match the block below it" show_diff "${expected}" "${got}" fi fi } # A declaration echoed from a header, or a fragment quoted out of an asset file # too large to show whole. Compiling or loading it is not the point -- the check # is that it still says what the file says, which is the drift that actually # happens and the one a compile could not catch. function run_excerpt() { local where="$1" body="$2" path="$3" if [ ! -r "${path}" ]; then fail "${where}" "excerpt=${path} names no such file" return fi RAN[excerpt]=$((RAN[excerpt] + 1)) if ! normalise_c "${path}" | grep -qF -- "$(normalise_c "${body}")"; then fail "${where}" "no longer appears in ${path}" echo "--- the documentation says ---" >&2 cat "${body}" >&2 echo "--- ${path} has drifted from it; comments and whitespace are ignored ---" >&2 fi } # An asset file, run through the loader that really reads it. # # The formats are documented in prose and read 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 is already invalid against the # loader it ships beside. tools/docs_checkjson.c closes it. function run_json() { local where="$1" body="$2" info="$3" local kind name preload preloads target local sandbox="${WORK}/json" log="${WORK}/json.log" local args=() kind="$(attr kind "${info}")" if [ -z "${kind}" ]; then fail "${where}" "a json block with no kind= (sprite, character, tilemap or properties)" return fi # A named block can be preloaded by a later one. Recorded whether or not the # validator is available, so omitting --checkjson does not turn a bad # preload= into a different failure. name="$(attr name "${info}")" if [ -n "${name}" ]; then JSON_KIND_OF["${name}"]="${kind}" JSON_BODY_OF["${name}"]="${body}" fi if [ -z "${CHECKJSON}" ] || [ ! -x "${CHECKJSON}" ]; then SKIPPED[nojson]=$((SKIPPED[nojson] + 1)) return fi rm -rf "${sandbox}" mkdir -p "${sandbox}" run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return # Three of the four formats are not self-contained: a character names sprites # that have to be in the registry already, and a tilemap names characters. # `preload=` lists the earlier named blocks that have to be loaded first, in # order -- which puts the dependency in front of the reader rather than in a # setup script they cannot see. preloads="$(attr preload "${info}")" for preload in ${preloads//,/ }; do if [ -z "${JSON_KIND_OF[${preload}]:-}" ]; then fail "${where}" "preload=${preload} names no earlier \`json name=${preload}\` block in this document" return fi cp "${JSON_BODY_OF[${preload}]}" "${sandbox}/${preload}.json" args+=("${JSON_KIND_OF[${preload}]}" "${preload}.json") done target="${name:-docs_example}" cp "${body}" "${sandbox}/${target}.json" args+=("${kind}" "${target}.json") if ( cd "${sandbox}" && \ SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \ "${CHECKJSON}" "${args[@]}" ) > "${log}" 2>&1; then RAN[json]=$((RAN[json] + 1)) else fail "${where}" "is not valid ${kind} JSON as far as the loader is concerned" sed -n '1,25p' "${log}" >&2 fi } # A shell command, in a sandbox. function run_sh() { local where="$1" body="$2" info="$3" expected="$4" local sandbox="${WORK}/sh" script="${WORK}/block.sh" got="${WORK}/got" rm -rf "${sandbox}" mkdir -p "${sandbox}" run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return # A leading "$ " is a prompt in a transcript, not part of the command. sed -e 's/^\$ //' "${body}" > "${script}" if ! ( cd "${sandbox}" && bash -e "${script}" ) > "${got}" 2>&1; then fail "${where}" "the command failed" sed -n '1,20p' "${got}" >&2 return fi RAN[sh]=$((RAN[sh] + 1)) if [ -n "${expected}" ]; then RAN[output]=$((RAN[output] + 1)) if ! cmp -s "${expected}" "${got}"; then fail "${where}" "output does not match the block below it" show_diff "${expected}" "${got}" fi fi } # ------------------------------------------------------------------ the pass for DOC in "${DOCS[@]}"; do BLOCKS="${WORK}/blocks" rm -rf "${BLOCKS}" mkdir -p "${BLOCKS}" : > "${BLOCKS}/index" extract "${DOC}" "${BLOCKS}" JSON_KIND_OF=() JSON_BODY_OF=() # Read the index into arrays first. An `output` block belongs to the block # before it, so each iteration has to be able to look one ahead. CLAIMED=0 IDS=(); LINES=(); INFOS=() while IFS=$'\t' read -r _id _line _info; do IDS+=("${_id}"); LINES+=("${_line}"); INFOS+=("${_info}") done < "${BLOCKS}/index" for ((n = 0; n < ${#IDS[@]}; n++)); do INFO="${INFOS[n]}" BODY="${BLOCKS}/${IDS[n]}" WHERE="${DOC}:${LINES[n]}" KIND="${INFO%% *}" # An `output` block belongs to the block above it, which claimed it on # the way past. One that nothing claimed is compared against nothing at # all -- indistinguishable from a passing test, and the exact failure # this harness exists to rule out. A *skipped* block still claims its # output: `norun` is a decision, not an accident. if [ "${KIND}" = "output" ]; then if [ "${CLAIMED}" -eq 0 ]; then fail "${WHERE}" "an output block with nothing runnable above it" fi CLAIMED=0 continue fi CLAIMED=0 EXPECTED="" if [ $((n + 1)) -lt ${#IDS[@]} ] && [ "${INFOS[n + 1]%% *}" = "output" ]; then case "${KIND}" in sh) EXPECTED="${BLOCKS}/${IDS[n + 1]}" CLAIMED=1 ;; c) if [ -n "$(attr run "${INFO}")" ]; then EXPECTED="${BLOCKS}/${IDS[n + 1]}" CLAIMED=1 fi ;; esac fi if has_word norun "${INFO}"; then SKIPPED[norun]=$((SKIPPED[norun] + 1)) continue fi # A `screenshot=NAME` block is the source of docs/images/NAME.png, # generated by tools/docs_screenshots.sh. The image is tracked, because a # reader on the forge has no build tree -- so nothing would otherwise # notice a figure that was tagged and never generated, and the chapter # would render a broken image. Whether the file is *of that listing* is # the docs_screenshots test's question, not this one's. SHOT="$(attr screenshot "${INFO}")" if [ -n "${SHOT}" ]; then if [ -r "docs/images/${SHOT}.png" ]; then RAN[screenshot]=$((RAN[screenshot] + 1)) else fail "${WHERE}" "screenshot=${SHOT} has no docs/images/${SHOT}.png; run \`cmake --build build --target docs_screenshots\`" fi fi case "${KIND}" in c) EXCERPT="$(attr excerpt "${INFO}")" if [ -n "${EXCERPT}" ]; then run_excerpt "${WHERE}" "${BODY}" "${EXCERPT}" elif [ -n "$(attr run "${INFO}")" ]; then run_c_run "${WHERE}" "${BODY}" "${INFO}" "${DOC}" "${LINES[n]}" "${EXPECTED}" elif [ -n "${SHOT}" ]; then # Compiled here as well as rendered there, so a figure's listing # is held to the same -Werror as every other snippet even in a # tree where the figures are not being regenerated. wrap= is # implied: a frame body is not a translation unit. run_c "${WHERE}" "${BODY}" "wrap=akglframe" "${DOC}" "${LINES[n]}" else run_c "${WHERE}" "${BODY}" "${INFO}" "${DOC}" "${LINES[n]}" fi ;; json) EXCERPT="$(attr excerpt "${INFO}")" if [ -n "${EXCERPT}" ]; then run_excerpt "${WHERE}" "${BODY}" "${EXCERPT}" else run_json "${WHERE}" "${BODY}" "${INFO}" fi ;; sh) run_sh "${WHERE}" "${BODY}" "${INFO}" "${EXPECTED}" ;; cmake) SKIPPED[cmake]=$((SKIPPED[cmake] + 1)) ;; # A block diagram, or a stack trace captured from a real run. There is no # executable claim in either, so the tag exists to say that out loud # rather than to let one sit here untagged and unnoticed. text) SKIPPED[text]=$((SKIPPED[text] + 1)) ;; norun) SKIPPED[norun]=$((SKIPPED[norun] + 1)) ;; "") fail "${WHERE}" "fenced block with no info string; tag it (see docs/MAINTENANCE.md)" ;; *) fail "${WHERE}" "unknown info string \"${INFO}\" (see docs/MAINTENANCE.md)" ;; esac done done # The count is part of the result, not decoration. A harness that passes because # a chapter stopped matching the extractor looks exactly like a harness that # passes because the documentation is correct, and this is what tells them apart. # A rising `norun` is the harness being talked out of its job. echo "ran: ${RAN[c]} c snippets, ${RAN[run]} linked programs, ${RAN[output]} output comparisons, ${RAN[excerpt]} excerpts, ${RAN[json]} json blocks, ${RAN[sh]} shell blocks, ${RAN[screenshot]} figures" echo "skipped: ${SKIPPED[norun]} norun, ${SKIPPED[cmake]} cmake, ${SKIPPED[text]} text, ${SKIPPED[nocc]} c (no compiler flags given), ${SKIPPED[nold]} run (no linker flags given), ${SKIPPED[nojson]} json (no validator given)" if [ "${FAILURES}" -eq 0 ]; then echo "PASS: every documented example does what the documentation says" fi exit "${FAILURES}"