## testmark.bash: read and patch testmark documents (data hunks embedded in markdown) from bash, and check output against them. ## ## Source this file, by a path containing a slash; it sources the rest of the library from beside itself. ## Needs bash 4.4 or later; on older ones, sourcing fails. ## Every public function is a shim over a private `_testmark_*` implementation, run through `_testmark_call`. ## Data comes out on stdout as exact bytes, and goes in on stdin; errors go to stderr as "testmark: ...", with a nonzero status. ## Data going in is normalized as parsing normalizes hunk bodies: CRLF becomes LF, and a missing final newline is added. ## Documents are addressed by a handle the caller picks at parse time; handles match `[A-Za-z0-9_]+`. # Checked first, in syntax bash 3.2 (macOS's /bin/bash) still runs, so an old bash gets one clear error. if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then >&2 echo "testmark: needs bash 4.4 or later; this is $BASH_VERSION" return 1 fi # shellcheck source=lib/testmark/document.bash source "${BASH_SOURCE[0]%/*}/testmark/document.bash" # shellcheck source=lib/testmark/patch.bash source "${BASH_SOURCE[0]%/*}/testmark/patch.bash" # shellcheck source=lib/testmark/check.bash source "${BASH_SOURCE[0]%/*}/testmark/check.bash" # shellcheck source=lib/testmark/run.bash source "${BASH_SOURCE[0]%/*}/testmark/run.bash" ## Runs an implementation function in a known environment, whatever the caller's settings. ## ## - `LC_ALL=C`: string operations work on bytes, and `[[:space:]]` is exactly ASCII whitespace. ## - `set -u`: a library bug that reads an unset variable exits the script loudly. `local -` restores the caller's setting. ## - `nocasematch` off: pattern matches are exact. It's restored afterwards, since `local -` doesn't cover shopts. ## - The `||` list: inside it, the caller's `set -e` and ERR trap can't fire on the implementation's ordinary nonzero statuses. _testmark_call() { local - LC_ALL=C _testmark_st=0 _testmark_nocase= set -u case ":$BASHOPTS:" in *:nocasematch:*) _testmark_nocase=1 && shopt -u nocasematch ;; esac "$@" || _testmark_st=$? [[ -z $_testmark_nocase ]] || shopt -s nocasematch return "$_testmark_st" } # The outermost entry is the script bash is running, unless there's none: then it's this file, alone. ((${#BASH_SOURCE[@]} < 2)) || _testmark_call _testmarktest_init "${BASH_SOURCE[-1]}" ## Parses FILE and makes it available under HANDLE. ## Parsing again under the same handle replaces the earlier document; if that parse fails, the handle is left unusable. ## ## - @param $1 handle ## - @param $2 file path; also used in error messages. ## - @stderr on failure, one line: "testmark: FILE:LINE: problem" for parse errors. testmark_parse() { _testmark_call _testmark_parse "$@"; } ## - @param $1 handle ## - @stdout the document's hunk names, one per line, in document order. testmark_names() { _testmark_call _testmark_names "$@"; } ## - @param $1 handle ## - @param $2 hunk name ## - @stdout the hunk's body, exactly: its lines, each ending in a newline, or nothing for an empty hunk. testmark_body() { _testmark_call _testmark_body "$@"; } ## - @param $1 handle ## - @param $2 hunk name ## - @stdout the hunk's info string (the opening fence's text after the backticks, trimmed), then a newline. testmark_info() { _testmark_call _testmark_info "$@"; } # The functions below change a document's state, so they refuse to run in a subshell, where the change would be lost: # feed them input with `testmark_check doc x < <(cmd)`, not `cmd | testmark_check doc x`, and don't call them inside `$(...)`. ## Stages a new body for a hunk, for testmark_write to write. Staging the same hunk again replaces what was staged before. ## ## - @param $1 handle ## - @param $2 hunk name ## - @param $3 optional: a new info string; the opening fence line becomes "```" and it. Without it, that line is kept as it is. ## - @stdin the new body, normalized as all input is. No line of it may start with "```". testmark_patch() { _testmark_call _testmark_patch "$@"; } ## Writes what's staged into the parsed file, in place, then parses the file again under the same handle. ## That drops everything staged and every record of what was checked. With nothing staged, it does nothing. ## ## - @param $1 handle testmark_write() { _testmark_call _testmark_write "$@"; } ## Checks output against a hunk's body, exactly. ## With TESTMARK_REGEN set, to any value, a mismatch stages the output as the hunk's new body instead, for testmark_write. ## In both modes, checking one hunk again with different output is an error. ## ## - @param $1 handle ## - @param $2 hunk name ## - @stdin the output, normalized as all input is, then compared exactly. ## - @return 0 if it matches, or if TESTMARK_REGEN staged it; 1 on a mismatch, or any error. ## - @stderr on a mismatch, a line saying so, then a `diff -u` of the expected and actual output. testmark_check() { _testmark_call _testmark_check "$@"; } # The high road: a test file makes rules, then runs them over its docs. # # exercise_todos() { ./todos.sh /dev/stdin; } # testmarktest_rule_basic '*/input' exercise_todos # testmarktest_run todos.md # # Rules and provided functions accumulate for the rest of the script. ## Makes every hunk whose name matches GLOB a case, to be exercised by FN. ## The case is the hunk's name minus its last `/`-segment; the hunk is its trigger. ## testmarktest_run runs FN for each case in a fresh bash, in the script's directory, with the trigger's body on stdin, ## and checks what FN prints, stdout and stderr together, against the case's `output` hunk, using testmark_check. ## If FN exits nonzero, a line `exit status: N` is added to its output before the check. ## ## FN runs under `set -euo pipefail`. It sees exported variables (except BASH_ENV, so its bash starts clean), but no other variables, and no functions but itself and those given to testmarktest_provide. ## ## - @param $1 GLOB: a bash pattern, matched against whole hunk names; `*` matches `/` too. ## - @param $2 FN: the name of a defined function. testmarktest_rule_basic() { _testmark_call _testmarktest_rule_basic "$@"; } ## Sends more functions into every case, for case functions that call them. ## ## - @param $@ names of defined functions. testmarktest_provide() { _testmark_call _testmarktest_provide "$@"; } ## Runs every rule's cases in each DOC, in document order, then writes each DOC's regen changes (see testmark_check). ## A rule that matches no hunk in any DOC, or a DOC with no match for any rule, is a failure. ## ## - @param $@ DOC paths, relative to the directory of the script being run. ## - @stdout one line per case: PASS or FAIL, the doc, the trigger's name, and the function. ## - @stderr each failure's details. ## - @return 0 if every case passed; 1 otherwise. It always runs every case. testmarktest_run() { _testmark_call _testmarktest_run "$@"; }