testmark-bash

Merge branch 'patch-check': testmark_patch, testmark_write, and testmark_check with TESTMARK_REGEN.

commit c972b45c76f26c2cdcd56c4ec47562df71399a71 · parent 4e822d05d7 · parent 19923c2a6a · browse this tree
Ximianth, 2026-10-24 01:09:52 UTC

diff --git a/lib/testmark.bash b/lib/testmark.bash
index f5789b5..050a507 100644
--- a/lib/testmark.bash
+++ b/lib/testmark.bash
@@ -1,9 +1,9 @@
-## testmark.bash: read 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.
 ## Every public function is a shim over a private `_testmark_*` implementation, run through `_testmark_call`.
-## Data comes out on stdout as exact bytes; errors go to stderr as "testmark: ...", with a nonzero status.
+## Data comes out on stdout as exact bytes, and goes in on stdin; errors go to stderr as "testmark: ...", with a nonzero status.
 ## 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.
@@ -14,6 +14,10 @@ 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"
 
 ## Runs an implementation function in a known environment, whatever the caller's settings.
 ##
@@ -51,3 +55,31 @@ testmark_body() { _testmark_call _testmark_body "$@"; }
 ## - @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, exactly; a final newline is added if it lacks one.  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: 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..3d1dcae
--- /dev/null
+++ b/lib/testmark/check.bash
@@ -0,0 +1,40 @@
+## 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 with different output is a harness bug, whether or not the first one matched.
+	# The same output again is compared like the first time, so a mismatch can't pass by repetition.
+	local -n checked=_testmark_checked_$1
+	[[ ${checked[$2]-$input} == "$input" ]] || {
+		_testmark_err "$1: hunk \"$2\": checked twice, with different output"
+		return 1
+	}
+	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/lib/testmark/document.bash b/lib/testmark/document.bash
index 2fe6e98..396bda8 100644
--- a/lib/testmark/document.bash
+++ b/lib/testmark/document.bash
@@ -8,10 +8,13 @@
 #   info         assoc by hunk name: the info string
 #   names        the hunk names in document order, each ending in "\n"
 #   pid          $BASHPID at parse, for refusing state changes from a subshell
+#   file         the file's absolute path, so a `cd` after parse can't send testmark_write elsewhere
+#   patch        assoc by hunk name: what testmark_write puts in place of the opener and body lines, [start - 1, end)
+#   checked      assoc by hunk name: the output testmark_check first saw, so a second, different one is an error
 # So forgetting a handle is one `unset` per kind, and nothing else needs cleaning up.
 # Variable names are built only from handles that pass _testmark_handle, and kinds have no "_", so two handles' variables never meet.
 # Hunk names come from documents and may contain shell syntax, so name-keyed elements are touched only as "Using assoc arrays safely" in docs/hacking/bash-pitfalls.md allows.
-_testmark_kinds=(lines start end info names pid)
+_testmark_kinds=(lines start end info names pid file patch checked)
 
 _testmark_err() {
 	>&2 printf 'testmark: %s\n' "$1"
@@ -106,12 +109,16 @@ _testmark_parse() {
 
 	# Parsed without error; only now are globals written.
 	# Assoc kinds are declared first; see "assoc arrays must be declared as such" in docs/hacking/bash-pitfalls.md.
-	declare -gA "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h"
+	declare -gA "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h" "_testmark_patch_$h" "_testmark_checked_$h"
 	local -n g_lines=_testmark_lines_$h g_start=_testmark_start_$h g_end=_testmark_end_$h g_info=_testmark_info_$h
-	local -n g_names=_testmark_names_$h g_pid=_testmark_pid_$h
+	local -n g_names=_testmark_names_$h g_pid=_testmark_pid_$h g_file=_testmark_file_$h g_patch=_testmark_patch_$h g_checked=_testmark_checked_$h
 	g_lines=("${lines[@]}")
 	for name in "${!start[@]}"; do g_start[$name]=${start[$name]} g_end[$name]=${end[$name]} g_info[$name]=${info[$name]}; done
 	g_names=$names g_pid=$BASHPID
+	g_file=$file
+	[[ $file == /* ]] || g_file=$PWD/$file
+	# Assigned, not just declared: set -u counts a declared but never assigned array as unset.
+	g_patch=() g_checked=()
 }
 
 ## Fails with a message unless $1 is the handle of a parsed document.
@@ -149,10 +156,17 @@ _testmark_body() {
 		return 1
 	}
 	_testmark_hunk "$1" "$2" || return 1
+	local body
+	_testmark_get_body "$1" "$2"
+	printf '%s' "$body"
+}
+
+## Sets the caller's local `body` to the body of hunk $2 in document $1, which must both exist.
+_testmark_get_body() {
 	local -n lines=_testmark_lines_$1 start=_testmark_start_$1 end=_testmark_end_$1
-	local i=${start[$2]} last=${end[$2]} body=
+	local i=${start[$2]} last=${end[$2]}
+	body=
 	for (( ; i < last; i++)); do body+=${lines[i]}; done # not a slice; see "Quadratic operations" in docs/hacking/bash-pitfalls.md
-	printf '%s' "$body"
 }
 
 _testmark_info() {
diff --git a/lib/testmark/patch.bash b/lib/testmark/patch.bash
new file mode 100644
index 0000000..f3b40fb
--- /dev/null
+++ b/lib/testmark/patch.bash
@@ -0,0 +1,91 @@
+## patch.bash: staging new hunk bodies, and writing them into the document's file.
+## 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
+
+## Fails with a message if this is a subshell of the shell that parsed document $1, which must exist.
+## Whatever a subshell stages is lost when it exits, so a regen there would vanish without a word.
+_testmark_same_shell() {
+	local -n pid=_testmark_pid_$1
+	[[ $pid == "$BASHPID" ]] || {
+		_testmark_err "$1: 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)'"
+		return 1
+	}
+}
+
+## Reads all of stdin into the caller's local `input`.
+##
+## - @param $1 what the input is for, as a prefix for the error message
+_testmark_input() {
+	# Status 0 means read stopped at a NUL; see "NUL bytes vanish unless you check for them" in docs/hacking/bash-pitfalls.md.
+	if IFS= read -r -d '' input; then
+		_testmark_err "$1: input contains a NUL byte"
+		return 1
+	fi
+}
+
+## Stages a new body for hunk $2 of document $1, which must both exist, replacing anything staged for it before.
+##
+## - @param $3 the body; a final newline is added if it lacks one
+## - @param $4 optional: the new opener line; without it, the hunk's own opener is kept
+_testmark_stage() {
+	local body=$3 opener=${4-}
+	[[ -z $body || $body == *$'\n' ]] || body+=$'\n'
+	# Any line starting with ``` closes the code block, so such a body would end its hunk early and corrupt the rest of the doc.
+	[[ $'\n'$body != *$'\n```'* ]] || {
+		_testmark_err "$1: hunk \"$2\": body has a line starting with \`\`\`, which would end its code block"
+		return 1
+	}
+	local -n lines=_testmark_lines_$1 start=_testmark_start_$1 patch=_testmark_patch_$1
+	local i=${start[$2]}
+	[[ -n $opener ]] || opener=${lines[i - 1]}
+	patch[$2]=$opener$body
+}
+
+_testmark_patch() {
+	(($# == 2 || $# == 3)) || {
+		_testmark_err "usage: testmark_patch HANDLE NAME [INFO] <BODY"
+		return 1
+	}
+	_testmark_hunk "$1" "$2" || return 1
+	_testmark_same_shell "$1" || return 1
+	local input opener=''
+	if (($# == 3)); then
+		# Parse trims info strings, and a newline would end the opener line, so neither would read back as given.
+		[[ $3 != *$'\n'* && $3 != [[:space:]]* && $3 != *[[:space:]] ]] || {
+			_testmark_err "$1: hunk \"$2\": info string must be one line, without leading or trailing whitespace"
+			return 1
+		}
+		opener='```'$3$'\n'
+	fi
+	_testmark_input "$1: hunk \"$2\"" || return 1
+	_testmark_stage "$1" "$2" "$input" "$opener"
+}
+
+_testmark_write() {
+	(($# == 1)) || {
+		_testmark_err "usage: testmark_write HANDLE"
+		return 1
+	}
+	_testmark_doc "$1" || return 1
+	_testmark_same_shell "$1" || return 1
+	local -n patch=_testmark_patch_$1
+	((${#patch[@]} > 0)) || return 0
+
+	# The output is a copy of the lines, where each staged hunk's opener line holds its staged opener and body, and its old body lines are emptied.
+	# Slicing out the lines between hunks instead would be quadratic; see "Quadratic operations" in docs/hacking/bash-pitfalls.md.
+	local -n lines=_testmark_lines_$1 start=_testmark_start_$1 end=_testmark_end_$1 file=_testmark_file_$1
+	local -a newlines=("${lines[@]}")
+	local name i last
+	for name in "${!patch[@]}"; do
+		i=${start[$name]} last=${end[$name]}
+		newlines[i - 1]=${patch[$name]}
+		for (( ; i < last; i++)); do newlines[i]=; done
+	done
+	# In place, so symlinks are followed and the mode is kept; >| so the caller's noclobber doesn't refuse it.
+	printf '%s' "${newlines[@]}" 2>/dev/null >|"$file" || {
+		_testmark_err "$file: not writable"
+		return 1
+	}
+	# Re-parsing drops what was staged and checked, along with the rest of the old state, and proves the output parses.
+	_testmark_parse "$1" "$file"
+}
diff --git a/tests/lib.sh b/tests/lib.sh
index d87f243..57b9371 100644
--- a/tests/lib.sh
+++ b/tests/lib.sh
@@ -50,6 +50,31 @@ expect_eq() {
 	fi
 }
 
+## Runs a command that should fail, and checks it printed exactly the given error and nothing on stdout.
+##
+## - @param $1 description
+## - @param $2 the expected stderr, without its final newline
+## - @param $3... the command
+expect_fail() {
+	local rc=0
+	"${@:3}" >"$work/out" 2>"$work/err" || rc=$?
+	if [[ $rc == 0 ]]; then
+		_fail "$1: succeeded; want a failure"
+	else
+		expect_eq "$1" "$(<"$work/err")$(<"$work/out")" "$2"
+	fi
+}
+
+## Runs a command in this shell, so it can change state, and checks it succeeded with nothing on stderr.
+##
+## - @param $1 description
+## - @param $2... the command
+expect_ok() {
+	local rc=0
+	"${@:2}" 2>"$work/err" || rc=$?
+	expect_eq "$1" "$rc $(<"$work/err")" "0 "
+}
+
 ## Quotes bytes the way the conformance harnesses do: printable ASCII as is, escapes for the rest.
 ##
 ## - @param $1 bytes
@@ -74,6 +99,14 @@ esc() {
 	printf '"%s"' "$out"
 }
 
+## - @param $1 file
+## - @stdout the file's bytes, exactly, quoted by `esc`
+bytes() {
+	local s
+	IFS= read -r -d '' s <"$1" || : # 1 is end of file; the test files hold no NUL
+	esc "$s"
+}
+
 ## Prints every hunk of a parsed document: `hunk`, `info`, and `body` lines, values quoted by `esc`.
 ##
 ## - @param $1 handle
diff --git a/tests/test-api.sh b/tests/test-api.sh
index d91b3c7..53e1389 100755
--- a/tests/test-api.sh
+++ b/tests/test-api.sh
@@ -5,31 +5,6 @@ cd "$(dirname "$0")"
 # shellcheck source=tests/lib.sh
 source ./lib.sh
 
-## Runs a command that should fail, and checks it printed exactly the given error and nothing on stdout.
-##
-## - @param $1 description
-## - @param $2 the expected stderr, without its final newline
-## - @param $3... the command
-expect_fail() {
-	local rc=0
-	"${@:3}" >"$work/out" 2>"$work/err" || rc=$?
-	if [[ $rc == 0 ]]; then
-		_fail "$1: succeeded; want a failure"
-	else
-		expect_eq "$1" "$(<"$work/err")$(<"$work/out")" "$2"
-	fi
-}
-
-## Runs a command in this shell, so it can change state, and checks it succeeded with nothing on stderr.
-##
-## - @param $1 description
-## - @param $2... the command
-expect_ok() {
-	local rc=0
-	"${@:2}" 2>"$work/err" || rc=$?
-	expect_eq "$1" "$rc $(<"$work/err")" "0 "
-}
-
 printf '[testmark]:# (x)\n```text\none\n```\n[testmark]:# (old)\n```\ngone\n```\n' >a.md
 printf '[testmark]:# (x)\n```\ntwo\n```\n' >b.md
 printf 'no hunks here\n' >none.md
diff --git a/tests/test-caller-env.sh b/tests/test-caller-env.sh
index d69f237..10446c3 100755
--- a/tests/test-caller-env.sh
+++ b/tests/test-caller-env.sh
@@ -26,6 +26,20 @@ expect_eq "errexit: a doc without a final newline" "$(testmark_body doc x)" "hel
 rc=0
 testmark_body doc nope 2>/dev/null || rc=$?
 expect_eq "errexit: an error returns, to a caller that checks" "$rc" 1
+
+# Patch, check, and write, called directly the same way; and write under noclobber, since it writes the file in place.
+printf '[testmark]:# (x)\n```\nhello\n```\n[testmark]:# (y)\n```\nhello\n```\n[testmark]:# (z)\n```\nhello\n```\n' >in.md
+testmark_parse doc in.md
+testmark_check doc x <<<'hello'
+rc=0
+testmark_check doc z <<<'HELLO' 2>/dev/null || rc=$?
+expect_eq "nocasematch: a check that differs only in case fails" "$rc" 1
+testmark_patch doc x <<<'patched'
+TESTMARK_REGEN=1 testmark_check doc y <<<'regen'
+set -C
+testmark_write doc
+set +C
+expect_eq "errexit, noclobber: patch, check, and write" "$(testmark_body doc x)$(testmark_body doc y)" "patchedregen"
 expect_eq "the ERR trap never fired" "$err_trap_fired" 0
 
 # The library turns on set -u for its own code only.
diff --git a/tests/test-check.sh b/tests/test-check.sh
new file mode 100755
index 0000000..9c709bf
--- /dev/null
+++ b/tests/test-check.sh
@@ -0,0 +1,151 @@
+#!/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 checked again like the first time, 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 || :
+		rc=0 want=0
+		"${run[@]}" testmark_check c a <<<"$first" 2>/dev/null || rc=$?
+		[[ $mode == regen || $first == alpha ]] || want=1
+		expect_eq "$mode: first $first, then the same again" "$rc" "$want"
+		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
new file mode 100755
index 0000000..b0938d7
--- /dev/null
+++ b/tests/test-patch.sh
@@ -0,0 +1,193 @@
+#!/bin/bash
+## Patching and writing: the written file's bytes, exactly, and every refusal.
+set -euo pipefail
+cd "$(dirname "$0")"
+# shellcheck source=tests/lib.sh
+source ./lib.sh
+
+## Writes a document, stages one patch, writes it, and checks the file's bytes, and that nothing went to stderr.
+##
+## - @param $1 case name
+## - @param $2 document, as a printf format
+## - @param $3 expected document after the write, as a printf format
+## - @param $4 the new body, as a printf format
+## - @param $5... testmark_patch's arguments after the handle: hunk name, and maybe an info string
+patch_case() {
+	local want
+	printf -- "$2" >in.md
+	testmark_parse p in.md
+	# An error shows in the compared value, rather than ending the script; the bytes written so far are still worth seeing.
+	{
+		testmark_patch p "${@:5}" < <(printf -- "$4")
+		testmark_write p
+	} 2>"$work/err" || :
+	printf -v want -- "$3"
+	expect_eq "$1" "$(bytes in.md)$(<"$work/err")" "$(esc "$want")"
+}
+
+DOC='prose\n\n[testmark]:# (x)\n```text\nold\n```\n\nafter\n'
+
+# --- bodies: only the lines between the fences change ---
+patch_case basic "$DOC" 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\n\nafter\n' 'new\n' x
+patch_case same-body "$DOC" "$DOC" 'old\n' x
+patch_case multi-line "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na\n\nb\n```\n\nafter\n' 'a\n\nb\n' x
+patch_case no-final-newline "$DOC" 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\n\nafter\n' 'new' x
+patch_case empty "$DOC" 'prose\n\n[testmark]:# (x)\n```text\n```\n\nafter\n' '' x
+patch_case one-blank-line-diverges "$DOC" 'prose\n\n[testmark]:# (x)\n```text\n\n```\n\nafter\n' '\n' x
+patch_case fill-empty '[testmark]:# (x)\n```\n```\n' '[testmark]:# (x)\n```\nnew\n```\n' 'new\n' x
+patch_case lone-cr "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na\rb\n```\n\nafter\n' 'a\rb\n' x
+patch_case backticks-not-at-line-start "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na ```\n  ```\n```\n\nafter\n' 'a ```\n  ```\n' x
+patch_case one-of-three '[testmark]:# (a)\n```\none\n```\n[testmark]:# (b)\n```\ntwo\n```\n[testmark]:# (c)\n```\nthree\n```\n' \
+	'[testmark]:# (a)\n```\none\n```\n[testmark]:# (b)\n```\nnew\n```\n[testmark]:# (c)\n```\nthree\n```\n' 'new\n' b
+
+# --- annotation, opener, and closer lines pass through as they are, unless given an info string ---
+patch_case info-change "$DOC" 'prose\n\n[testmark]:# (x)\n```json\nnew\n```\n\nafter\n' 'new\n' x json
+patch_case info-clear "$DOC" 'prose\n\n[testmark]:# (x)\n```\nnew\n```\n\nafter\n' 'new\n' x ''
+patch_case info-words "$DOC" 'prose\n\n[testmark]:# (x)\n```a b\nnew\n```\n\nafter\n' 'new\n' x 'a b'
+patch_case ann-trailing-text '[testmark]:# (x more words)\n```\nold\n```\n' '[testmark]:# (x more words)\n```\nnew\n```\n' 'new\n' x
+patch_case opener-spacing-kept '[testmark]:# (x)\n```  text \t\nold\n```\n' '[testmark]:# (x)\n```  text \t\nnew\n```\n' 'new\n' x
+patch_case closer-trailing-text '[testmark]:# (x)\n```\nold\n``` trailing\n' '[testmark]:# (x)\n```\nnew\n``` trailing\n' 'new\n' x
+patch_case four-backticks '[testmark]:# (x)\n````md\nold\n````\n' '[testmark]:# (x)\n````md\nnew\n````\n' 'new\n' x
+patch_case four-backticks-info-change '[testmark]:# (x)\n````md\nold\n````\n' '[testmark]:# (x)\n```json\nnew\n````\n' 'new\n' x json
+
+# --- line endings: CRLF is written back as LF, and a missing final newline stays missing ---
+patch_case crlf-whole-doc 'prose\r\n\r\n[testmark]:# (x)\r\n```text\r\nold\r\n```\r\nafter\r\n' 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\nafter\n' 'new\n' x
+patch_case doc-no-final-newline '[testmark]:# (x)\n```\nold\n```' '[testmark]:# (x)\n```\nnew\n```' 'new\n' x
+patch_case doc-no-final-newline-prose "${DOC%\\n}" 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\n\nafter' 'new\n' x
+
+# --- several patches, and staging again ---
+printf '[testmark]:# (a)\n```\none\n```\n\n[testmark]:# (b)\n```text\ntwo\n```\n' >two.md
+## - @param $1 expected two.md, as a printf format
+## - @stdout two.md's bytes, then the expected bytes, each quoted by `esc` on a line of its own
+two_md() {
+	local want
+	printf -v want -- "$1"
+	printf '%s\n%s\n' "$(bytes two.md)" "$(esc "$want")"
+}
+cp two.md orig.md
+testmark_parse t two.md
+testmark_patch t b <<<'new b'
+testmark_patch t a <<<'new a'
+testmark_write t
+mapfile -t got < <(two_md '[testmark]:# (a)\n```\nnew a\n```\n\n[testmark]:# (b)\n```text\nnew b\n```\n')
+expect_eq "two patches, staged in reverse order" "${got[0]}" "${got[1]}"
+expect_eq "after a write, the handle reads the written file" "$(testmark_body t a)" "new a"
+
+cp orig.md two.md
+testmark_parse t two.md
+testmark_patch t a <<<'first'
+testmark_patch t a <<<'second'
+testmark_patch t b json <<<'first'
+testmark_patch t b <<<'second'
+testmark_write t
+mapfile -t got < <(two_md '[testmark]:# (a)\n```\nsecond\n```\n\n[testmark]:# (b)\n```text\nsecond\n```\n')
+expect_eq "staging again replaces all of what was staged, info string included" "${got[0]}" "${got[1]}"
+
+cp orig.md two.md
+testmark_parse t two.md
+testmark_patch t a <<<'staged'
+expect_fail "a refused patch" 'testmark: t: hunk "a": body has a line starting with ```, which would end its code block' testmark_patch t a <<<'```'
+testmark_write t
+mapfile -t got < <(two_md '[testmark]:# (a)\n```\nstaged\n```\n\n[testmark]:# (b)\n```text\ntwo\n```\n')
+expect_eq "leaves what was staged before" "${got[0]}" "${got[1]}"
+
+# --- what a write touches, and what it leaves behind ---
+printf '[testmark]:# (x)\n```\nold\n```\n' >w.md
+testmark_parse w w.md
+rm w.md
+mkdir w.md
+expect_ok "a write with nothing staged" testmark_write w
+expect_eq "doesn't open the file" "$([[ -d w.md ]] && echo dir)" dir
+testmark_patch w x <<<'new'
+expect_fail "a write that can't open the file" "testmark: $work/w.md: not writable" testmark_write w
+rmdir w.md
+
+mkdir sub
+printf '[testmark]:# (x)\n```\nold\n```\n' >sub/cd.md
+cd sub
+testmark_parse c cd.md
+cd ..
+testmark_patch c x <<<'new'
+testmark_write c
+expect_eq "a cd between parse and write: writes the parsed file" "$(bytes sub/cd.md)" "$(esc $'[testmark]:# (x)\n```\nnew\n```\n')"
+expect_eq "and nothing else" "$([[ -e cd.md ]] || echo absent)" absent
+
+printf '[testmark]:# (x)\n```\nold\n```\n' >real.md
+chmod 640 real.md
+ln -s real.md link.md
+testmark_parse l link.md
+testmark_patch l x <<<'new'
+testmark_write l
+expect_eq "a symlink: is kept" "$([[ -L link.md ]] && echo link)" link
+expect_eq "and its target written, in place" "$(bytes real.md) $(stat -c %a real.md)" "$(esc $'[testmark]:# (x)\n```\nnew\n```\n') 640"
+
+## - @stdout `declare -p` of every `_testmark_*` global
+globals() {
+	local vars
+	mapfile -t vars < <(compgen -v _testmark_)
+	declare -p "${vars[@]}"
+}
+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
+expect_eq "a write leaves the same state as a fresh parse of what it wrote" "$after_write" "$(globals)"
+
+# --- refusals ---
+printf '[testmark]:# (x)\n```\nold\n```\n' >r.md
+testmark_parse r r.md
+FENCE='body has a line starting with ```, which would end its code block'
+INFO='info string must be one line, without leading or trailing whitespace'
+expect_fail "unknown hunk" 'testmark: r: no hunk named "nope"' testmark_patch r nope <<<'x'
+expect_fail "unknown handle" 'testmark: unknown handle "nope"' testmark_patch nope x <<<'x'
+expect_fail "write: unknown handle" 'testmark: unknown handle "nope"' testmark_write nope
+expect_fail "a fence line first" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf '```\nx\n')
+expect_fail "a fence line later" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf 'x\n```json\n')
+expect_fail "a fence line without its newline" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf 'x\n```')
+expect_fail "a four-backtick fence line" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf '````\n')
+expect_fail "info: a newline" "testmark: r: hunk \"x\": $INFO" testmark_patch r x $'a\nb' <<<'x'
+expect_fail "info: a final newline" "testmark: r: hunk \"x\": $INFO" testmark_patch r x $'a\n' <<<'x'
+expect_fail "info: leading space" "testmark: r: hunk \"x\": $INFO" testmark_patch r x ' a' <<<'x'
+expect_fail "info: trailing tab" "testmark: r: hunk \"x\": $INFO" testmark_patch r x $'a\t' <<<'x'
+expect_fail "a NUL byte" 'testmark: r: hunk "x": input contains a NUL byte' testmark_patch r x < <(printf 'a\0b\n')
+expect_fail "patch arity, too few" 'testmark: usage: testmark_patch HANDLE NAME [INFO] <BODY' testmark_patch r <<<'x'
+expect_fail "patch arity, too many" 'testmark: usage: testmark_patch HANDLE NAME [INFO] <BODY' testmark_patch r x json more <<<'x'
+expect_fail "write arity" 'testmark: usage: testmark_write HANDLE' testmark_write r r
+expect_ok "after all that" testmark_write r
+expect_eq "nothing was staged" "$(bytes r.md)" "$(esc $'[testmark]:# (x)\n```\nold\n```\n')"
+
+# --- the subshell guard ---
+SUBSHELL="testmark: r: 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)'"
+patch_from_pipe() { printf 'new\n' | testmark_patch r x; }
+patch_in_subst() {
+	local got
+	got=$(testmark_patch r x <<<'new')
+}
+write_in_subst() {
+	local got
+	got=$(testmark_write r)
+}
+expect_fail "patch from a pipe" "$SUBSHELL" patch_from_pipe
+expect_fail "patch inside \$(...)" "$SUBSHELL" patch_in_subst
+testmark_patch r x <<<'new'
+expect_fail "write inside \$(...)" "$SUBSHELL" write_in_subst
+expect_eq "leaves the file alone" "$(bytes r.md)" "$(esc $'[testmark]:# (x)\n```\nold\n```\n')"
+expect_ok "patch with < <(...)" testmark_patch r x < <(printf 'new\n')
+
+# --- names with shell syntax: plain data through staging and writing too ---
+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 testmark_patch h "$name" <<<"new $name"; done
+testmark_write h
+for name in "${names[@]}"; do
+	expect_eq "patched $name" "$(testmark_body h "$name")" "new $name"
+done
+expect_eq "nothing was executed" "$(echo pwned*)" "pwned*"
+
+exit "$RESULT"