testmark-bash

Check layer: testmark_check compares output to a hunk, or under TESTMARK_REGEN stages it.

commit 4a4248cccf5b70c6fcce803cec7019ef983c724f · parent af11966ce4 · browse this tree
Ximianth, 2026-10-24 00:22:31 UTC

Follows testmark-rs's Checker (crates/testmark/src/check.rs):
- an absent hunk is an error in both modes; regen rewrites hunks, it doesn't add them.
- the first output checked against a hunk is recorded, matched or not,
  and a later check with different output is an error, in both modes.
- a mismatch returns 1, with a diff -u of expected and actual on stderr,
  labelled with the file and hunk name.
- in regen mode, a mismatch stages the output, keeping the opener line.

Unlike rs, output no hunk could hold is refused in both modes:
no final newline, or a CRLF (a body never has either, after parse).
rs regens such output on every run and never converges.

The record of what was checked is the "checked" kind, so testmark_write's
re-parse drops it with everything else.
The subshell guard applies in normal mode too, since the record is state.

The tests assert in plain bash, so a broken check layer can't vouch for itself,
and include a hunk of glob characters, since an unquoted right-hand side
in either comparison would make it a pattern.
diff --git a/lib/testmark.bash b/lib/testmark.bash
index e8e7c5e..050a507 100644
--- a/lib/testmark.bash
+++ b/lib/testmark.bash
@@ -1,4 +1,4 @@
-## testmark.bash: read and patch testmark documents (data hunks embedded in markdown) from 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.
@@ -16,6 +16,8 @@ fi
 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"
 
 ## Runs an implementation function in a known environment, whatever the caller's settings.
 ##
@@ -70,3 +72,14 @@ testmark_patch() { _testmark_call _testmark_patch "$@"; }
 ##
 ## - @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: empty, or lines each ending in a newline, without CRLF.
+## - @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 "$@"; }
diff --git a/lib/testmark/check.bash b/lib/testmark/check.bash
new file mode 100644
index 0000000..64f4c2d
--- /dev/null
+++ b/lib/testmark/check.bash
@@ -0,0 +1,42 @@
+## check.bash: checking output against a hunk, or under TESTMARK_REGEN, staging it as the hunk's new body.
+## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`.
+# shellcheck disable=SC2034,SC2178 # shellcheck does not follow namerefs; see document.bash
+
+_testmark_check() {
+	(($# == 2)) || {
+		_testmark_err "usage: testmark_check HANDLE NAME <ACTUAL"
+		return 1
+	}
+	_testmark_hunk "$1" "$2" || return 1
+	_testmark_same_shell "$1" || return 1
+	local input body
+	_testmark_input "$1: hunk \"$2\"" || return 1
+	# A body is "" or lines ending in LF, never CRLF, so other output could never match, and regen would rewrite it on every run.
+	[[ -z $input || ($input == *$'\n' && $input != *$'\r\n'*) ]] || {
+		_testmark_err "$1: hunk \"$2\": output must be empty or end in a newline, and contain no CRLF; normalize it before checking"
+		return 1
+	}
+
+	# Two checks claiming one hunk is a harness bug, whether or not the first one matched.
+	local -n checked=_testmark_checked_$1
+	if [[ -n ${checked[$2]+set} ]]; then
+		[[ ${checked[$2]} == "$input" ]] || {
+			_testmark_err "$1: hunk \"$2\": checked twice, with different output"
+			return 1
+		}
+		return 0
+	fi
+	checked[$2]=$input
+
+	_testmark_get_body "$1" "$2"
+	[[ $input != "$body" ]] || return 0
+	if [[ -n ${TESTMARK_REGEN+set} ]]; then
+		_testmark_stage "$1" "$2" "$input"
+		return
+	fi
+	local -n file=_testmark_file_$1
+	_testmark_err "$1: hunk \"$2\": output doesn't match; set TESTMARK_REGEN=1 to accept it"
+	# diff's status isn't checked: the comparison above has decided already, and diff is only here to show it.
+	>&2 diff -u --label "$file: $2 (expected)" --label "$file: $2 (actual)" <(printf '%s' "$body") <(printf '%s' "$input")
+	return 1
+}
diff --git a/tests/test-check.sh b/tests/test-check.sh
new file mode 100755
index 0000000..0f61e6f
--- /dev/null
+++ b/tests/test-check.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+## Checking output against hunks, and regenerating them under TESTMARK_REGEN.
+## These assert in plain bash, not through testmark_check itself, so a broken check layer can't pass its own tests.
+set -euo pipefail
+cd "$(dirname "$0")"
+# shellcheck source=tests/lib.sh
+source ./lib.sh
+
+DOC='[testmark]:# (a)\n```text\nalpha\n```\n\n[testmark]:# (empty)\n```\n```\n\n[testmark]:# (blank)\n```\n\n```\n\n[testmark]:# (glob)\n```\n*\n```\n'
+printf -- "$DOC" >c.md
+unset TESTMARK_REGEN
+
+## Runs a command in this shell, with TESTMARK_REGEN set.
+env_regen() { TESTMARK_REGEN=1 "$@"; }
+
+## Parses c.md afresh as handle c, checks one output against one hunk, and checks the status.
+##
+## - @param $1 description
+## - @param $2 hunk name
+## - @param $3 the output, as a printf format
+## - @param $4 the expected status
+check_case() {
+	local rc=0
+	testmark_parse c c.md
+	testmark_check c "$2" < <(printf -- "$3") 2>"$work/err" || rc=$?
+	expect_eq "$1" "$rc" "$4"
+}
+
+# --- normal mode: exact comparison ---
+check_case "match" a 'alpha\n' 0
+check_case "a wrong expectation fails" a 'omega\n' 1
+check_case "only the case differs" a 'ALPHA\n' 1
+check_case "one newline more" a 'alpha\n\n' 1
+check_case "empty hunk, empty output" empty '' 0
+check_case "empty hunk, one newline" empty '\n' 1
+check_case "one-blank-line hunk, one newline (diverges)" blank '\n' 0
+check_case "one-blank-line hunk, empty output" blank '' 1
+check_case "a body of glob characters matches only itself" glob 'x\n' 1
+check_case "and itself" glob '*\n' 0
+
+testmark_parse c c.md
+expect_fail "a mismatch shows a diff" "$(
+	cat <<-EOF
+		testmark: c: hunk "a": output doesn't match; set TESTMARK_REGEN=1 to accept it
+		--- $work/c.md: a (expected)
+		+++ $work/c.md: a (actual)
+		@@ -1 +1,2 @@
+		-alpha
+		+omega
+		+two
+	EOF
+)" testmark_check c a < <(printf 'omega\ntwo\n')
+expect_ok "and stages nothing" testmark_write c
+expect_eq "so the file is unchanged" "$(bytes c.md)" "$(esc "$(printf -- "$DOC")"$'\n')"
+
+# --- regen mode ---
+testmark_parse c c.md
+expect_ok "regen: a mismatch succeeds" env_regen testmark_check c a <<<'omega'
+expect_ok "regen: a match succeeds" env_regen testmark_check c blank <<<''
+expect_ok "regen: and the write" testmark_write c
+expect_eq "writes only the mismatch, keeping its info string" "$(bytes c.md)" \
+	"$(esc "$(printf -- "${DOC/alpha/omega}")"$'\n')"
+expect_ok "and then the same output checks clean" testmark_check c a <<<'omega'
+printf -- "$DOC" >c.md
+
+testmark_parse c c.md
+TESTMARK_REGEN='' expect_ok "regen: TESTMARK_REGEN set but empty is regen too" testmark_check c a <<<'omega'
+testmark_write c
+expect_eq "and writes" "$(testmark_body c a)" "omega"
+printf -- "$DOC" >c.md
+
+# --- both modes ---
+FENCE='body has a line starting with ```, which would end its code block'
+UNHOLDABLE='output must be empty or end in a newline, and contain no CRLF; normalize it before checking'
+for mode in normal regen; do
+	run=()
+	[[ $mode == normal ]] || run=(env_regen)
+	testmark_parse c c.md
+	expect_fail "$mode: absent hunk" 'testmark: c: no hunk named "nope"' "${run[@]}" testmark_check c nope <<<'x'
+	expect_fail "$mode: no final newline" "testmark: c: hunk \"a\": $UNHOLDABLE" "${run[@]}" testmark_check c a < <(printf 'omega')
+	expect_fail "$mode: CRLF" "testmark: c: hunk \"a\": $UNHOLDABLE" "${run[@]}" testmark_check c a < <(printf 'omega\r\n')
+	expect_fail "$mode: a NUL byte" 'testmark: c: hunk "a": input contains a NUL byte' "${run[@]}" testmark_check c a < <(printf 'a\0b\n')
+	expect_fail "$mode: arity" 'testmark: usage: testmark_check HANDLE NAME <ACTUAL' "${run[@]}" testmark_check c <<<'x'
+	expect_ok "$mode: none of which staged anything" testmark_write c
+	expect_eq "$mode: so the file is unchanged" "$(bytes c.md)" "$(esc "$(printf -- "$DOC")"$'\n')"
+
+	# Checking a hunk twice: the same output is fine, other output is an error, whether or not the first check matched.
+	for first in alpha omega; do
+		testmark_parse c c.md
+		"${run[@]}" testmark_check c a <<<"$first" 2>/dev/null || :
+		expect_ok "$mode: first $first, then the same again" "${run[@]}" testmark_check c a <<<"$first"
+		expect_fail "$mode: first $first, then other output" 'testmark: c: hunk "a": checked twice, with different output' "${run[@]}" testmark_check c a <<<'other'
+		expect_fail "$mode: first $first, then glob characters" 'testmark: c: hunk "a": checked twice, with different output' "${run[@]}" testmark_check c a <<<'*'
+	done
+done
+
+testmark_parse c c.md
+expect_fail "regen: output with a fence line" "testmark: c: hunk \"a\": $FENCE" env_regen testmark_check c a < <(printf 'x\n```\n')
+
+testmark_parse c c.md
+env_regen testmark_check c a <<<'omega'
+testmark_write c
+expect_fail "a write drops what was checked: other output is a mismatch, not a second check" "$(
+	cat <<-EOF
+		testmark: c: hunk "a": output doesn't match; set TESTMARK_REGEN=1 to accept it
+		--- $work/c.md: a (expected)
+		+++ $work/c.md: a (actual)
+		@@ -1 +1 @@
+		-omega
+		+other
+	EOF
+)" testmark_check c a <<<'other'
+printf -- "$DOC" >c.md
+
+# --- the subshell guard, in both modes ---
+SUBSHELL="testmark: c: called in a subshell (from a pipe, or inside \$(...)), where any change to the document is lost; instead of 'cmd | testmark_check HANDLE NAME', use 'testmark_check HANDLE NAME < <(cmd)'"
+check_from_pipe() { printf 'omega\n' | "$@" testmark_check c a; }
+check_in_subst() {
+	local got
+	# shellcheck disable=SC2034 # only the call inside $(...) matters, not its output
+	got=$("$@" testmark_check c a <<<'omega')
+}
+testmark_parse c c.md
+expect_fail "normal: from a pipe" "$SUBSHELL" check_from_pipe
+expect_fail "normal: inside \$(...)" "$SUBSHELL" check_in_subst
+expect_fail "regen: from a pipe" "$SUBSHELL" check_from_pipe env_regen
+expect_fail "regen: inside \$(...)" "$SUBSHELL" check_in_subst env_regen
+expect_ok "none of which staged anything" testmark_write c
+expect_eq "so the file is unchanged" "$(bytes c.md)" "$(esc "$(printf -- "$DOC")"$'\n')"
+expect_ok "regen with < <(...)" env_regen testmark_check c a < <(printf 'omega\n')
+
+# --- names with shell syntax: plain data through checking and regen ---
+names=('$(>pwned1)' '`>pwned2`' 'a]b' 'x[0]' '@' '*' '$HOME' "'q'" '"q"' 'a\b')
+for name in "${names[@]}"; do
+	printf '[testmark]:# (%s)\n```\nold\n```\n\n' "$name"
+done >syntax.md
+testmark_parse h syntax.md
+for name in "${names[@]}"; do env_regen testmark_check h "$name" <<<"new $name"; done
+for name in "${names[@]}"; do
+	expect_fail "checked $name twice" "testmark: h: hunk \"$name\": checked twice, with different output" testmark_check h "$name" <<<'other'
+done
+testmark_write h
+for name in "${names[@]}"; do
+	expect_ok "regenerated $name" testmark_check h "$name" <<<"new $name"
+done
+expect_eq "nothing was executed" "$(echo pwned*)" "pwned*"
+
+exit "$RESULT"
diff --git a/tests/test-patch.sh b/tests/test-patch.sh
index 15ef504..b0938d7 100755
--- a/tests/test-patch.sh
+++ b/tests/test-patch.sh
@@ -130,6 +130,7 @@ globals() {
 printf '[testmark]:# (x)\n```\nold\n```\n[testmark]:# (y)\n```\nold\n```\n' >g.md
 testmark_parse g g.md
 testmark_patch g x json <<<'new'
+testmark_check g y <<<'old'
 testmark_write g
 after_write=$(globals)
 testmark_parse g g.md