testmark-bash

testmark.bash

## 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.
##
## Bash's own runtime error messages from inside these functions (a command not found, an unbound variable) are relabeled in the output:
## `FN: message`, with no path and no line number, so the text is the same wherever the test runs.
## Set TESTMARK_RAW_ERRORS, to any value, to keep bash's text verbatim instead.
##
## - @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 "$@"; }

lib/testmark.bash · 7.1 KiB · 1c29154087