testmark-bash

run: testmarktest_rule_basic, testmarktest_provide, and testmarktest_run, a table runner for fixture tests.

commit e85d9b9d379e50c8fe0fc8e8b521e73f34034f44 · parent 423732f80f · browse this tree
Ximianth, 2026-10-24 14:37:29 UTC

A test file says which hunks are cases and which function exercises them, then runs its docs:

	exercise_todos() { ./todos.sh /dev/stdin; }
	testmarktest_rule_basic '*/input' exercise_todos
	testmarktest_run todos.md

Each case runs in a fresh bash, under set -euo pipefail, in the script's directory, with the trigger hunk's body on stdin.
What it prints, stdout and stderr together, is checked against the case's `output` hunk,
with a line `exit status: N` added if it exits nonzero, and a marker if it lacks a final newline:
so a fixture for a tool that works is just its output, and a failing tool can't pass.
The run reports a line per case, writes each doc's regen changes, and fails if any case failed,
or any rule or doc matched nothing, which is how a typo'd glob would otherwise pass by running no cases.

A fresh process per case is what makes set -e dependable in case functions, whatever the caller's shell is doing;
it costs about half a millisecond a case over a subshell.
The case gets only its function and those given to testmarktest_provide, sent as `declare -f`; never the library.
The script is fed through a process substitution rather than -c:
bash's own errors in a case then read the same on 4.4 through 5.3 ("/dev/fd/63: line N: ..."), and land in fixtures that way.
diff --git a/lib/testmark.bash b/lib/testmark.bash
index 050a507..9ed18c8 100644
--- a/lib/testmark.bash
+++ b/lib/testmark.bash
@@ -18,6 +18,8 @@ source "${BASH_SOURCE[0]%/*}/testmark/document.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.
 ##
@@ -34,6 +36,9 @@ _testmark_call() {
 	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.
 ##
@@ -83,3 +88,38 @@ testmark_write() { _testmark_call _testmark_write "$@"; }
 ## - @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.
+## Before the check, if FN's output doesn't end in a newline, it gets one, then a line `\ No newline at end of output`;
+## and if FN exits nonzero, a line `exit status: N` is added.
+##
+## FN runs under `set -euo pipefail`.  It sees exported variables, 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 "$@"; }
diff --git a/lib/testmark/run.bash b/lib/testmark/run.bash
new file mode 100644
index 0000000..39be5af
--- /dev/null
+++ b/lib/testmark/run.bash
@@ -0,0 +1,154 @@
+## 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' "$?"
+	)
+	[[ -z $stream || $stream == *$'\n' ]] || stream+=$'\n\\ No newline at end of output\n'
+	((st == 0)) || stream+="exit status: $st"$'\n'
+	_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"
+}
diff --git a/tests/test-run.sh b/tests/test-run.sh
new file mode 100755
index 0000000..5d9f38e
--- /dev/null
+++ b/tests/test-run.sh
@@ -0,0 +1,410 @@
+#!/bin/bash
+## The high road: testmarktest_rule_basic, testmarktest_provide, and testmarktest_run.
+## Each case writes a doc and a test script, then runs the script in a fresh bash, from another directory.
+## These assert in plain bash, not through the runner itself, so a broken runner can't pass its own tests.
+set -euo pipefail
+cd "$(dirname "$0")"
+# shellcheck source=tests/lib.sh
+source ./lib.sh
+
+unset TESTMARK_REGEN
+mkdir t
+
+## Writes t/doc.md and t/test.sh, runs the script from $work, and checks what it printed and its status.
+## The script gets a line sourcing the library first.  $work in the output shows as WORK.
+##
+## - @param $1 description
+## - @param $2 t/doc.md's content
+## - @param $3 the rest of t/test.sh
+## - @param $4 the expected stdout, then a line `--- stderr`, then the expected stderr, then `--- status N`
+run_case() {
+	printf '%s' "$2" >t/doc.md
+	printf 'source %q\n%s\n' "$ROOT/lib/testmark.bash" "$3" >t/test.sh
+	local rc=0 got
+	"$BASH" t/test.sh >"$work/out" 2>"$work/err" </dev/null || rc=$?
+	got="$(<"$work/out")"$'\n'"--- stderr"$'\n'"$(<"$work/err")"$'\n'"--- status $rc"
+	got=${got//$work/WORK}
+	expect_eq "$1" "$got" "$4"
+}
+
+## Checks t/doc.md's bytes, as `bytes` quotes them.
+##
+## - @param $1 description
+## - @param $2 the expected document, as a printf format
+doc_is() {
+	expect_eq "$1" "$(bytes t/doc.md)" "$(esc "$(printf -- "$2" && printf x)" | sed 's/x"$/"/')"
+}
+
+## Prints a doc with one case, `a`: `a/in` holds "hello", and `a/output` holds $1, which must be empty or end in a newline.
+one() {
+	printf '[testmark]:# (a/in)\n```\nhello\n```\n\n[testmark]:# (a/output)\n```\n%s```\n' "$1"
+}
+
+# --- passing, failing, and what the output stream holds ---
+
+run_case "a match passes" "$(one $'hello\n')" '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_cat)
+--- stderr
+
+--- status 0"
+
+run_case "a mismatch fails, with a diff" "$(one $'bye\n')" '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md' "FAIL  doc.md: a/in (exercise_cat)
+--- stderr
+testmark: testmarktest_run: hunk \"a/output\": output doesn't match; set TESTMARK_REGEN=1 to accept it
+--- WORK/t/doc.md: a/output (expected)
++++ WORK/t/doc.md: a/output (actual)
+@@ -1 +1 @@
+-bye
++hello
+--- status 1"
+
+run_case "stderr is in the stream, in order" "$(one $'out\nerr\nout\n')" '
+exercise_both() { echo out; echo err >&2; echo out; }
+testmarktest_rule_basic "*/in" exercise_both
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_both)
+--- stderr
+
+--- status 0"
+
+run_case "a nonzero exit is a line of output" "$(one $'hello\nexit status: 3\n')" '
+exercise_fail() { cat; return 3; }
+testmarktest_rule_basic "*/in" exercise_fail
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_fail)
+--- stderr
+
+--- status 0"
+
+run_case "no final newline gets a marker" "$(one $'x\n\\ No newline at end of output\n')" '
+exercise_x() { printf x; }
+testmarktest_rule_basic "*/in" exercise_x
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_x)
+--- stderr
+
+--- status 0"
+
+run_case "the marker, then the exit status" "$(one $'x\n\\ No newline at end of output\nexit status: 1\n')" '
+exercise_x() { printf x; false; }
+testmarktest_rule_basic "*/in" exercise_x
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_x)
+--- stderr
+
+--- status 0"
+
+run_case "empty output matches an empty hunk" "$(one '')" '
+exercise_quiet() { :; }
+testmarktest_rule_basic "*/in" exercise_quiet
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_quiet)
+--- stderr
+
+--- status 0"
+
+run_case "CRLF fails the case" "$(one '')" '
+exercise_crlf() { printf "a\r\n"; }
+testmarktest_rule_basic "*/in" exercise_crlf
+testmarktest_run doc.md' "FAIL  doc.md: a/in (exercise_crlf)
+--- stderr
+testmark: testmarktest_run: hunk \"a/output\": output must be empty or end in a newline, and contain no CRLF; normalize it before checking
+--- status 1"
+
+run_case "a NUL byte fails the case" "$(one '')" '
+exercise_nul() { printf "a\0b\n"; }
+testmarktest_rule_basic "*/in" exercise_nul
+testmarktest_run doc.md' "FAIL  doc.md: a/in (exercise_nul)
+--- stderr
+testmark: testmarktest_run: hunk \"a/in\": output contains a NUL byte
+--- status 1"
+
+# --- the case's process ---
+
+run_case "stdin is the trigger's body, exactly" $'[testmark]:# (a/in)\n```\n  two\\\n\n```\n\n[testmark]:# (a/output)\n```\n  two\\\n\n|\n```\n\n[testmark]:# (b/in)\n```\n```\n\n[testmark]:# (b/output)\n```\n|\n```\n' '
+exercise_bar() { cat; echo "|"; }
+testmarktest_rule_basic "*/in" exercise_bar
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_bar)
+PASS  doc.md: b/in (exercise_bar)
+--- stderr
+
+--- status 0"
+
+run_case "set -e works in a case, even with the run under if and ||" "$(one $'exit status: 1\n')" '
+exercise_e() { false; echo unreached; }
+testmarktest_rule_basic "*/in" exercise_e
+if testmarktest_run doc.md; then echo ran; fi
+testmarktest_run doc.md || echo failed' "PASS  doc.md: a/in (exercise_e)
+ran
+PASS  doc.md: a/in (exercise_e)
+--- stderr
+
+--- status 0"
+
+run_case "pipefail is on in a case" "$(one $'exit status: 1\n')" '
+exercise_p() { false | true; echo unreached; }
+testmarktest_rule_basic "*/in" exercise_p
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_p)
+--- stderr
+
+--- status 0"
+
+run_case "nounset is on in a case" "$(one $'/dev/fd/63: line 4: nope: unbound variable\nexit status: 1\n')" '
+exercise_u() { echo "$nope"; }
+testmarktest_rule_basic "*/in" exercise_u
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_u)
+--- stderr
+
+--- status 0"
+
+run_case "the caller's set -e doesn't stop a run partway" '[testmark]:# (a/in)
+```
+```
+
+[testmark]:# (a/output)
+```
+wrong
+```
+
+[testmark]:# (b/in)
+```
+```
+
+[testmark]:# (b/output)
+```
+```' '
+set -euo pipefail
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md
+echo unreached' "FAIL  doc.md: a/in (exercise_cat)
+PASS  doc.md: b/in (exercise_cat)
+--- stderr
+testmark: testmarktest_run: hunk \"a/output\": output doesn't match; set TESTMARK_REGEN=1 to accept it
+--- WORK/t/doc.md: a/output (expected)
++++ WORK/t/doc.md: a/output (actual)
+@@ -1 +0,0 @@
+-wrong
+--- status 1"
+
+run_case "the case runs in the script's dir; docs are found there" "$(one "$work/t"$'\n')" '
+exercise_pwd() { pwd; }
+testmarktest_rule_basic "*/in" exercise_pwd
+cd /
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_pwd)
+--- stderr
+
+--- status 0"
+
+run_case "exported variables cross; others don't" "$(one $'yes\nunset\n')" '
+export EXPORTED=yes
+NOT_EXPORTED=no
+exercise_env() { echo "$EXPORTED"; echo "${NOT_EXPORTED-unset}"; }
+testmarktest_rule_basic "*/in" exercise_env
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_env)
+--- stderr
+
+--- status 0"
+
+run_case "the caller's exported LC_ALL crosses, not the library's" "$(one $'C.UTF-8\n')" '
+export LC_ALL=C.UTF-8
+exercise_lc() { echo "$LC_ALL"; }
+testmarktest_rule_basic "*/in" exercise_lc
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_lc)
+--- stderr
+
+--- status 0"
+
+run_case "an unset LC_ALL stays unset in the case" "$(one $'unset\n')" '
+unset LC_ALL
+exercise_lc() { echo "${LC_ALL-unset}"; }
+testmarktest_rule_basic "*/in" exercise_lc
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_lc)
+--- stderr
+
+--- status 0"
+
+run_case "other functions don't cross, the library included" "$(one $'/dev/fd/63: line 5: helper: command not found\nexit status: 127\n')" '
+helper() { echo helped; }
+exercise_h() { declare -F | grep testmark || :; helper; }
+testmarktest_rule_basic "*/in" exercise_h
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_h)
+--- stderr
+
+--- status 0"
+
+run_case "provided functions cross" "$(one $'helped\n')" '
+helper() { echo helped; }
+exercise_h() { helper; }
+testmarktest_provide helper
+testmarktest_rule_basic "*/in" exercise_h
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_h)
+--- stderr
+
+--- status 0"
+
+# --- regen ---
+
+run_case "regen writes the output" "$(one $'bye\n')" '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+TESTMARK_REGEN=1 testmarktest_run doc.md && testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_cat)
+PASS  doc.md: a/in (exercise_cat)
+--- stderr
+
+--- status 0"
+expect_eq "regen wrote the doc" "$(<t/doc.md)" "$(one $'hello\n')"
+
+run_case "regen writes every doc, and only what changed" "$(one $'hello\n')" '
+printf "%s\n" "[testmark]:# (b/in)" "\`\`\`" "x" "\`\`\`" "" "[testmark]:# (b/output)" "\`\`\`text" "\`\`\`" >"${BASH_SOURCE%/*}/two.md"
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+TESTMARK_REGEN=1 testmarktest_run doc.md two.md
+cat "${BASH_SOURCE%/*}/two.md"' "PASS  doc.md: a/in (exercise_cat)
+PASS  two.md: b/in (exercise_cat)
+[testmark]:# (b/in)
+\`\`\`
+x
+\`\`\`
+
+[testmark]:# (b/output)
+\`\`\`text
+x
+\`\`\`
+--- stderr
+
+--- status 0"
+expect_eq "regen left an unchanged doc as it was" "$(<t/doc.md)" "$(one $'hello\n')"
+
+# --- what makes a run fail, besides a case ---
+
+run_case "a missing output hunk fails the case" '[testmark]:# (a/in)
+```
+```' '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md
+TESTMARK_REGEN=1 testmarktest_run doc.md' "FAIL  doc.md: a/in (exercise_cat)
+FAIL  doc.md: a/in (exercise_cat)
+--- stderr
+testmark: testmarktest_run: no hunk named \"a/output\"
+testmark: testmarktest_run: no hunk named \"a/output\"
+--- status 1"
+
+run_case "a trigger without a / fails" '[testmark]:# (in)
+```
+```' '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*in" exercise_cat
+testmarktest_run doc.md' "FAIL  doc.md: in (exercise_cat)
+--- stderr
+testmark: testmarktest_run: hunk \"in\": has no \"/\", so it names no case
+--- status 1"
+
+run_case "a rule matching nothing fails the run, after every case runs" "$(one $'hello\n')" '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/inn" exercise_cat
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_cat)
+--- stderr
+testmark: rule \"*/inn\" exercise_cat: matched no hunk
+--- status 1"
+
+run_case "a doc with no match fails the run" "$(one $'hello\n')" '
+printf "%s\n" "[testmark]:# (b/script)" "\`\`\`" "\`\`\`" >"${BASH_SOURCE%/*}/two.md"
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run two.md doc.md' "PASS  doc.md: a/in (exercise_cat)
+--- stderr
+testmark: two.md: no hunk matches any rule
+--- status 1"
+
+run_case "a doc that doesn't parse fails the run; the others still run" "$(one $'hello\n')" '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run nope.md doc.md' "FAIL  nope.md
+PASS  doc.md: a/in (exercise_cat)
+--- stderr
+testmark: WORK/t/nope.md: not a readable file
+--- status 1"
+
+run_case "a run with no rules is an error" "$(one '')" '
+testmarktest_run doc.md' "
+--- stderr
+testmark: testmarktest_run: no rules; make them with testmarktest_rule_basic first
+--- status 1"
+
+# --- several rules ---
+
+run_case "rules pick out their own hunks, in document order" '[testmark]:# (one/script)
+```
+echo one
+```
+
+[testmark]:# (one/output)
+```
+one
+```
+
+[testmark]:# (two/in)
+```
+two
+```
+
+[testmark]:# (two/output)
+```
+two
+```
+
+[testmark]:# (three/script)
+```
+echo three
+```
+
+[testmark]:# (three/output)
+```
+three
+```' '
+exercise_script() { bash; }
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/script" exercise_script
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md' "PASS  doc.md: one/script (exercise_script)
+PASS  doc.md: two/in (exercise_cat)
+PASS  doc.md: three/script (exercise_script)
+--- stderr
+
+--- status 0"
+
+run_case "a glob's * matches /; the case is all but the last segment" '[testmark]:# (a/b/c/in)
+```
+deep
+```
+
+[testmark]:# (a/b/c/output)
+```
+deep
+```' '
+exercise_cat() { cat; }
+testmarktest_rule_basic "*/in" exercise_cat
+testmarktest_run doc.md' "PASS  doc.md: a/b/c/in (exercise_cat)
+--- stderr
+
+--- status 0"
+
+# --- arguments ---
+
+defined() { :; }
+expect_fail "rule: no arguments" 'testmark: usage: testmarktest_rule_basic GLOB FN' testmarktest_rule_basic
+expect_fail "rule: one argument" 'testmark: usage: testmarktest_rule_basic GLOB FN' testmarktest_rule_basic '*/in'
+expect_fail "rule: three arguments" 'testmark: usage: testmarktest_rule_basic GLOB FN' testmarktest_rule_basic '*/in' defined output
+expect_fail "rule: an empty glob" 'testmark: testmarktest_rule_basic: the glob is empty' testmarktest_rule_basic '' defined
+expect_fail "rule: not a function" 'testmark: testmarktest_rule_basic: "cat" is not a defined function' testmarktest_rule_basic '*/in' cat
+expect_fail "provide: no arguments" 'testmark: usage: testmarktest_provide FN...' testmarktest_provide
+expect_fail "provide: not a function" 'testmark: testmarktest_provide: "nope" is not a defined function' testmarktest_provide defined nope
+expect_fail "run: no arguments" 'testmark: usage: testmarktest_run DOC...' testmarktest_run
+expect_eq "refused calls leave no rules" "${#_testmarktest_fns[@]} ${#_testmarktest_provided[@]}" "0 0"
+got=$("$BASH" -c 'source "$1"; f() { :; }; testmarktest_rule_basic "*" f; testmarktest_run doc.md' _ "$ROOT/lib/testmark.bash" 2>&1) || got+=" (status $?)"
+expect_eq "run: not from a script file" "$got" 'testmark: testmarktest_run: no script file to find the docs beside; run it from a script (status 1)'
+
+exit "$RESULT"