## run.bash: the high road -- rules that make hunks into cases, and a runner that exercises each case and checks its output. ## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`. # Rules, in the order made, as parallel arrays: rule i is glob i and function i. _testmarktest_globs=() _testmarktest_fns=() # Functions sent into every case, besides the rule's own. _testmarktest_provided=() # The directory of the script being run, absolute; empty if bash isn't running a script file. # Set when the library is sourced, so a later `cd` can't move it. _testmarktest_dir= ## Sets _testmarktest_dir to the directory of script $1, if it's a file. _testmarktest_init() { local top=$1 [[ -f $top ]] || return 0 [[ $top == */* ]] || top=./$top _testmarktest_dir=${top%/*} _testmarktest_dir=${_testmarktest_dir:-/} [[ $_testmarktest_dir == /* ]] || _testmarktest_dir=$PWD/$_testmarktest_dir } ## Fails with a message unless $2 is a defined function; $1 is the public function asking, for the message. _testmarktest_need_fn() { declare -F -- "$2" >/dev/null || { _testmark_err "$1: \"$2\" is not a defined function" return 1 } } _testmarktest_rule_basic() { (($# == 2)) || { _testmark_err "usage: testmarktest_rule_basic GLOB FN" return 1 } [[ -n $1 ]] || { _testmark_err "testmarktest_rule_basic: the glob is empty" return 1 } _testmarktest_need_fn testmarktest_rule_basic "$2" || return 1 _testmarktest_globs+=("$1") _testmarktest_fns+=("$2") } _testmarktest_provide() { (($# > 0)) || { _testmark_err "usage: testmarktest_provide FN..." return 1 } local fn for fn; do _testmarktest_need_fn testmarktest_provide "$fn" || return 1; done _testmarktest_provided+=("$@") } _testmarktest_run() { (($# > 0)) || { _testmark_err "usage: testmarktest_run DOC..." return 1 } ((${#_testmarktest_fns[@]} > 0)) || { _testmark_err "testmarktest_run: no rules; make them with testmarktest_rule_basic first" return 1 } [[ -n $_testmarktest_dir ]] || { _testmark_err "testmarktest_run: no script file to find the docs beside; run it from a script" return 1 } local -a matched=() local i doc failed=0 for i in "${!_testmarktest_fns[@]}"; do matched[i]=0; done for doc; do _testmarktest_doc "$doc" || failed=1; done for i in "${!_testmarktest_fns[@]}"; do ((matched[i] > 0)) || { _testmark_err "rule \"${_testmarktest_globs[i]}\" ${_testmarktest_fns[i]}: matched no hunk" failed=1 } done return "$failed" } ## Runs every case in doc $1, reports each, and writes the doc's regen patches. ## Counts each rule's triggers in the caller's `matched`. _testmarktest_doc() { # The handle shows in the check layer's messages, so it's named for who's speaking. local doc=$1 path=$1 h=testmarktest_run [[ $path == /* ]] || path=$_testmarktest_dir/$path _testmark_parse "$h" "$path" || { printf 'FAIL %s\n' "$doc" return 1 } local -a names local name i failed=0 any= mapfile -t names < <(_testmark_names "$h") for name in "${names[@]}"; do for i in "${!_testmarktest_globs[@]}"; do # shellcheck disable=SC2053 # the glob is a pattern on purpose [[ $name == ${_testmarktest_globs[i]} ]] || continue matched[i]=$((matched[i] + 1)) any=1 if _testmarktest_case "$h" "$name" "${_testmarktest_fns[i]}"; then printf 'PASS %s: %s (%s)\n' "$doc" "$name" "${_testmarktest_fns[i]}" else printf 'FAIL %s: %s (%s)\n' "$doc" "$name" "${_testmarktest_fns[i]}" failed=1 fi done done [[ -n $any ]] || { _testmark_err "$doc: no hunk matches any rule" failed=1 } _testmark_write "$h" || failed=1 return "$failed" } ## Exercises the case of trigger $2 in document $1 with function $3, and checks its output against the case's `output` hunk. _testmarktest_case() { [[ $2 == */* ]] || { _testmark_err "$1: hunk \"$2\": has no \"/\", so it names no case" return 1 } local out=${2%/*}/output stream st _testmark_hunk "$1" "$out" || return 1 # The exit status follows the output after a NUL, which bash can't hold; so a second NUL means the output held one. { IFS= read -r -d '' stream if IFS= read -r -d '' st; then _testmark_err "$1: hunk \"$2\": output contains a NUL byte" return 1 fi } < <( _testmarktest_exec "$1" "$2" "$3" printf '\0%s' "$?" ) if ((st != 0)); then [[ -z $stream || $stream == *$'\n' ]] || stream+=$'\n' stream+="exit status: $st"$'\n' fi _testmark_check "$1" "$out" < <(printf '%s' "$stream") } ## Runs function $3 in a fresh bash, in the script's directory, with the body of hunk $2 of document $1 on stdin. ## stdout and stderr both go to stdout. Runs in a process substitution, so its `cd` and `unset` change nothing outside. _testmarktest_exec() { # _testmark_call's `local LC_ALL=C` would reach the case if the caller exported LC_ALL; unsetting it uncovers the caller's own. unset -v LC_ALL cd "$_testmarktest_dir" 2>&1 || return "$BASH" <(_testmarktest_script "$3") < <(_testmark_body "$1" "$2") 2>&1 } ## Prints the script a case runs: strict mode, the functions it may call, and a call to function $1. ## This is the one place a case's script is built. _testmarktest_script() { printf 'set -euo pipefail\n' declare -f -- "$1" "${_testmarktest_provided[@]}" printf '%q\n' "$1" }