testmark-bash

Merge branch 'storage-per-handle': per-handle globals, parse into locals and commit once.

commit 64a113ffaf8cac17133414aa146f04f1e4962c2b · parent 6117ccc1a3 · parent 3c9f75eb9f · browse this tree
Ximianth, 2026-10-23 14:51:31 UTC

A handle's state lives only in globals named after it, so forgetting it is one unset per kind;
parse works in locals and writes globals only after it succeeds, so a failed parse leaves nothing behind.
Plus review fixes: one handle check, one list of kinds, comments trimmed to one home per fact.
diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash
index f44a15a..d6f8899 100644
--- a/lib/testmark/document.bash
+++ b/lib/testmark/document.bash
@@ -1,72 +1,70 @@
 ## document.bash: parsing a testmark document, and reading its hunks.
 ## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`.
+# shellcheck disable=SC2034,SC2178 # shellcheck does not follow namerefs: writes through them look unused, and reusing a name as one looks like an array turned string
 
-# Storage: private, and declared `-g` so sourcing from inside a function works; `+=()` makes them set-u safe, and keeps contents if sourced twice.
-# Each parse appends its lines to _testmark_lines at _testmark_next; forgetting a handle unsets its lines, leaving a hole.
-# Per-hunk entries are keyed "HANDLE:NAME", and forgetting a handle rebuilds those arrays without its keys.
-# Names are hostile input as keys: touch keyed elements only as `a[$k]=v`, `"${a[$k]}"`, `"${a[$k]-}"`, and `${a[$k]+set}`.
-# In particular never in arithmetic, and never with `unset`: those can run `$(...)` from inside a key.
-# Handles once checked against [A-Za-z0-9_]+, and line indexes (the library's own numbers), are safe in any form.
-declare -ga _testmark_lines # every document's lines, each keeping its "\n" (a file's last line may lack one), CRLF made LF
-_testmark_lines+=()
-declare -g _testmark_next=${_testmark_next-0} # next free index in _testmark_lines; with holes, the element count isn't it
-declare -gA _testmark_base _testmark_count    # by handle: its lines' range in _testmark_lines, recorded as soon as they're read, so a failed parse's lines get freed too
-_testmark_base+=() _testmark_count+=()
-declare -gA _testmark_pid _testmark_names # by handle: $BASHPID at parse (empty while unusable; state changes refuse other pids), names each ending in "\n"
-_testmark_pid+=() _testmark_names+=()
-declare -gA _testmark_start _testmark_end _testmark_info # by "HANDLE:NAME": body line range [start, end) in _testmark_lines, and info string
-_testmark_start+=() _testmark_end+=() _testmark_info+=()
+# Storage: a document's state lives only in globals named after its handle, `_testmark_KIND_HANDLE`, one per kind in _testmark_kinds:
+#   lines        indexed: the lines, each keeping its "\n" (a file's last line may lack one), CRLF made LF
+#   start, end   assoc by hunk name: the body's line range [start, end) in lines
+#   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
+# 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: on some bash versions, arithmetic, `[[ -v ]]`, `unset`, and `printf -v` on an element run `$(...)` from inside its key.
+# So touch name-keyed elements only as `a[$k]=v`, `"${a[$k]}"`, `"${a[$k]-}"`, `${a[$k]+set}`, and `"${!a[@]}"`.
+_testmark_kinds=(lines start end info names pid)
 
 _testmark_err() {
 	>&2 printf 'testmark: %s\n' "$1"
 }
 
+## Fails with a message unless $1 is a valid handle.
+_testmark_handle() {
+	[[ $1 =~ ^[A-Za-z0-9_]+$ ]] || {
+		_testmark_err "invalid handle \"$1\": must match [A-Za-z0-9_]+"
+		return 1
+	}
+}
+
 _testmark_parse() {
 	(($# == 2)) || {
 		_testmark_err "usage: testmark_parse HANDLE FILE"
 		return 1
 	}
-	local h=$1 file=$2
-	[[ $h =~ ^[A-Za-z0-9_]+$ ]] || {
-		_testmark_err "invalid handle \"$h\": must match [A-Za-z0-9_]+"
-		return 1
-	}
-	_testmark_pid[$h]=
-	_testmark_forget "$h"
+	local h=$1 file=$2 kind
+	_testmark_handle "$h" || return 1
+	for kind in "${_testmark_kinds[@]}"; do unset -v "_testmark_${kind}_$h"; done
 	# A directory would read as zero lines, so it's rejected here.
 	[[ -f $file && -r $file ]] || {
 		_testmark_err "$file: not a readable file"
 		return 1
 	}
-	# Everything from _testmark_next on is unset, so the growth in element count is the number of lines read.
-	local base=$_testmark_next before=${#_testmark_lines[@]} end
-	mapfile -O "$base" _testmark_lines <"$file" || {
+	local -a lines
+	mapfile lines <"$file" || {
 		_testmark_err "$file: read failed"
 		return 1
 	}
-	end=$((base + ${#_testmark_lines[@]} - before))
-	_testmark_next=$end _testmark_base[$h]=$base _testmark_count[$h]=$((end - base))
 
 	# State: `open` while inside a code block; `k` from an annotation until its hunk closes; `at` is the line number of the annotation or opener.
-	local i n line name info names='' open='' k='' at=''
-	for ((i = base; i < end; i++)); do
-		line=${_testmark_lines[i]}
-		n=$((i - base + 1))
+	local -A start=() end=() info=()
+	local i line name names='' open='' k='' at=''
+	for ((i = 0; i < ${#lines[@]}; i++)); do
+		line=${lines[i]}
 		# mapfile ends a line at a NUL, dropping its "\n".  So a line without one is the last line, or has a NUL; read -d '' tells which.
 		if [[ $line != *$'\n' ]] && IFS= read -r -d '' _ <"$file"; then
-			_testmark_err "$file:$n: contains a NUL byte"
+			_testmark_err "$file:$((i + 1)): contains a NUL byte"
 			return 1
 		fi
 		if [[ $line == *$'\r\n' ]]; then
 			line=${line%$'\r\n'}
-			_testmark_lines[i]=$line$'\n'
+			lines[i]=$line$'\n'
 		else
 			line=${line%$'\n'}
 		fi
 
 		if [[ -n $open ]]; then
 			if [[ $line == '```'* ]]; then
-				[[ -z $k ]] || _testmark_end[$k]=$i
+				[[ -z $k ]] || end[$k]=$i
 				open='' k=''
 			fi
 		elif [[ -n $k ]]; then
@@ -74,30 +72,28 @@ _testmark_parse() {
 				_testmark_err "$file:$at: annotation not followed by a code block"
 				return 1
 			}
-			info=${line#'```'}
+			line=${line#'```'}
 			# Trim: strip the leading run of whitespace, then the trailing one.
-			info=${info#"${info%%[![:space:]]*}"}
-			info=${info%"${info##*[![:space:]]}"}
-			_testmark_info[$k]=$info
-			_testmark_start[$k]=$((i + 1))
-			open=1 at=$n
+			line=${line#"${line%%[![:space:]]*}"}
+			info[$k]=${line%"${line##*[![:space:]]}"}
+			start[$k]=$((i + 1))
+			open=1 at=$((i + 1))
 		elif [[ $line == '```'* ]]; then
-			open=1 at=$n
+			open=1 at=$((i + 1))
 		elif [[ $line == '[testmark]:#'* ]]; then
 			name=${line#'[testmark]:# ('}
 			name=${name%')'}
 			if [[ $line != '[testmark]:# ('*')' || ! $name =~ ^[[:space:]]*([^[:space:]]+) ]]; then
-				_testmark_err "$file:$n: malformed annotation; want \"[testmark]:# (name)\""
+				_testmark_err "$file:$((i + 1)): malformed annotation; want \"[testmark]:# (name)\""
 				return 1
 			fi
-			name=${BASH_REMATCH[1]}
-			k=$h:$name
-			[[ -z ${_testmark_start[$k]+set} ]] || {
-				_testmark_err "$file:$n: duplicate hunk name \"$name\""
+			k=${BASH_REMATCH[1]}
+			[[ -z ${start[$k]+set} ]] || {
+				_testmark_err "$file:$((i + 1)): duplicate hunk name \"$k\""
 				return 1
 			}
-			names+=$name$'\n'
-			at=$n
+			names+=$k$'\n'
+			at=$((i + 1))
 		fi
 	done
 	if [[ -n $open ]]; then
@@ -109,34 +105,30 @@ _testmark_parse() {
 		return 1
 	}
 
-	_testmark_names[$h]=$names
-	_testmark_pid[$h]=$BASHPID
+	# Parsed without error; only now are globals written.
+	# Assoc kinds are declared first, since a nameref to an undeclared name makes an indexed array.
+	declare -gA "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$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
+	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
 }
 
-## Drops handle $1's lines, and its per-hunk entries.
-## The per-hunk arrays are rebuilt without them, since unsetting an element keyed by a hunk name has no spelling that's safe for every key and setting.
-_testmark_forget() {
-	local i base=${_testmark_base[$1]-0} count=${_testmark_count[$1]-0}
-	for ((i = base; i < base + count; i++)); do unset -v "_testmark_lines[$i]"; done
-	local k
-	local -A start=() end=() info=()
-	for k in "${!_testmark_start[@]}"; do [[ $k == "$1:"* ]] || start[$k]=${_testmark_start[$k]}; done
-	for k in "${!_testmark_end[@]}"; do [[ $k == "$1:"* ]] || end[$k]=${_testmark_end[$k]}; done
-	for k in "${!_testmark_info[@]}"; do [[ $k == "$1:"* ]] || info[$k]=${_testmark_info[$k]}; done
-	_testmark_start=() _testmark_end=() _testmark_info=()
-	for k in "${!start[@]}"; do _testmark_start[$k]=${start[$k]}; done
-	for k in "${!end[@]}"; do _testmark_end[$k]=${end[$k]}; done
-	for k in "${!info[@]}"; do _testmark_info[$k]=${info[$k]}; done
-}
-
-## Sets the caller's local `k` to the storage key of hunk $2 in document $1, or fails with a message.
-_testmark_hunk() {
-	[[ -n ${_testmark_pid[$1]-} ]] || {
+## Fails with a message unless $1 is the handle of a parsed document.
+_testmark_doc() {
+	_testmark_handle "$1" || return 1
+	[[ -v _testmark_pid_$1 ]] || {
 		_testmark_err "unknown handle \"$1\""
 		return 1
 	}
-	k=$1:$2
-	[[ -n ${_testmark_start[$k]+set} ]] || {
+}
+
+## Fails with a message unless $1 is the handle of a parsed document with a hunk named $2.
+_testmark_hunk() {
+	_testmark_doc "$1" || return 1
+	local -n start=_testmark_start_$1
+	[[ -n ${start[$2]+set} ]] || {
 		_testmark_err "$1: no hunk named \"$2\""
 		return 1
 	}
@@ -147,11 +139,9 @@ _testmark_names() {
 		_testmark_err "usage: testmark_names HANDLE"
 		return 1
 	}
-	[[ -n ${_testmark_pid[$1]-} ]] || {
-		_testmark_err "unknown handle \"$1\""
-		return 1
-	}
-	printf '%s' "${_testmark_names[$1]}"
+	_testmark_doc "$1" || return 1
+	local -n names=_testmark_names_$1
+	printf '%s' "$names"
 }
 
 _testmark_body() {
@@ -159,10 +149,10 @@ _testmark_body() {
 		_testmark_err "usage: testmark_body HANDLE NAME"
 		return 1
 	}
-	local k i start end body=
 	_testmark_hunk "$1" "$2" || return 1
-	start=${_testmark_start[$k]} end=${_testmark_end[$k]}
-	for ((i = start; i < end; i++)); do body+=${_testmark_lines[i]}; done
+	local -n lines=_testmark_lines_$1 start=_testmark_start_$1 end=_testmark_end_$1
+	local i=${start[$2]} last=${end[$2]} body=
+	for (( ; i < last; i++)); do body+=${lines[i]}; done
 	printf '%s' "$body"
 }
 
@@ -171,7 +161,7 @@ _testmark_info() {
 		_testmark_err "usage: testmark_info HANDLE NAME"
 		return 1
 	}
-	local k
 	_testmark_hunk "$1" "$2" || return 1
-	printf '%s\n' "${_testmark_info[$k]}"
+	local -n info=_testmark_info_$1
+	printf '%s\n' "${info[$2]}"
 }
diff --git a/tests/lib.sh b/tests/lib.sh
index 54f633b..d87f243 100644
--- a/tests/lib.sh
+++ b/tests/lib.sh
@@ -1,7 +1,7 @@
 #!/bin/bash
 ## Shared helpers for the testmark-bash tests.  Sourced, not executed.
 ## Sources the library, makes a scratch dir and cd's into it, and provides PASS/FAIL reporting.
-## The helpers stay exact under the hostile settings test-hostile-env.sh turns on:
+## The helpers stay exact under the unusual settings test-caller-env.sh turns on:
 ## comparisons use `[ = ]`, which `nocasematch` doesn't touch.
 # shellcheck disable=SC2034 # RESULT is read by the test scripts
 
@@ -12,11 +12,21 @@ TESTDATA=$ROOT/tests/testdata
 # shellcheck source=lib/testmark.bash
 source "$ROOT/lib/testmark.bash"
 
+RESULT=0
 work=$(mktemp -d)
-trap 'rm -rf "$work"' EXIT
+exec {stderr}>&2
+trap _exit EXIT
 cd "$work"
 
-RESULT=0
+## A nonzero exit with no FAIL row means the script died, maybe with the library's message in $work/err; this shows it before deleting $work.
+## It writes to the saved stderr: a shell that dies inside a redirect runs this trap with the redirect still in place.
+_exit() {
+	local rc=$?
+	if ((rc != 0 && RESULT == 0)) && [[ -s $work/err ]]; then
+		>&"$stderr" printf 'died (exit %s); last captured stderr:\n%s\n' "$rc" "$(<"$work/err")"
+	fi
+	rm -rf "$work"
+}
 
 _pass() {
 	printf 'PASS  %s\n' "$1"
@@ -90,3 +100,14 @@ dump_parse() {
 		printf 'error: %s\n' "$(<"$work/err")"
 	fi
 }
+
+## Parses a document given as a printf format, and checks its dump against the expected lines.
+##
+## - @param $1 case name
+## - @param $2 document, as a printf format
+## - @param $3... expected dump lines
+parse_case() {
+	printf -- "$2" >in.md
+	dump_parse in.md >got # not in $(...), so a library bug that kills the shell kills this script too
+	expect_eq "$1" "$(<got)" "$(printf '%s\n' "${@:3}")"
+}
diff --git a/tests/test-api.sh b/tests/test-api.sh
index 550cae1..d91b3c7 100755
--- a/tests/test-api.sh
+++ b/tests/test-api.sh
@@ -63,57 +63,58 @@ expect_eq "and it reads" "$(testmark_body b x)" "two"
 expect_fail "a failed re-parse fails" 'testmark: missing.md: not a readable file' testmark_parse b missing.md
 expect_fail "and leaves its handle unusable" 'testmark: unknown handle "b"' testmark_names b
 
-# --- re-parsing frees the old lines: white-box, counting the private lines array in a fresh shell ---
-printf '[testmark]:# (x)\n```\nnever closed\n' >unclosed.md
-## In a fresh shell: runs `testmark_parse HANDLE FILE` for each HANDLE=FILE argument, ignoring failures, then evals a probe.
-##
-## - @param $1 the probe: bash code
-## - @param $2... HANDLE=FILE
-## - @stdout the probe's
-in_fresh_shell() {
-	"$BASH" -c 'source "$1"; probe=$2; shift 2; for a; do testmark_parse "${a%%=*}" "${a#*=}" 2>/dev/null || :; done; eval "$probe"' \
-		_ "$ROOT/lib/testmark.bash" "$@"
+# --- what a parse doesn't keep is gone: white-box, comparing every library global and its value ---
+printf '[testmark]:# (x)\n```\none\n```\n[testmark]:# (y)\n' >dangling.md
+## - @stdout `declare -p` of every `_testmark_*` global
+globals() {
+	local vars
+	mapfile -t vars < <(compgen -v _testmark_)
+	declare -p "${vars[@]}"
 }
-count='echo "${#_testmark_lines[@]}"'
-expect_eq "after a re-parse, only the live docs' lines are kept" "$(in_fresh_shell "$count" d=a.md e=b.md d=b.md)" "$((4 + 4))"
-expect_eq "after a failed parse and a good one" "$(in_fresh_shell "$count" d=unclosed.md d=a.md)" "8"
-expect_eq "after a failed parse, then another handle, then a good one" "$(in_fresh_shell "$count" d=unclosed.md e=b.md d=a.md)" "$((4 + 8))"
-expect_eq "new docs don't land on live docs' lines after a re-parse" "$(in_fresh_shell 'testmark_body e x' d=a.md e=b.md d=b.md f=a.md)" "two"
+before=$(globals)
+testmark_parse g a.md
+expect_fail "a parse that fails late" 'testmark: dangling.md:5: annotation not followed by a code block' testmark_parse g dangling.md
+expect_eq "leaves no globals for its handle, of the old doc or the partial one" "$(globals)" "$before"
+testmark_parse g b.md
+before=$(globals)
+testmark_parse g a.md
+testmark_parse g b.md
+expect_eq "a re-parse leaves nothing of the old doc" "$(globals)" "$before"
 
 # --- sourcing: twice keeps state; inside a function still makes globals ---
+testmark_parse s a.md
 # shellcheck source=lib/testmark.bash
 source "$ROOT/lib/testmark.bash"
-expect_eq "sourcing again keeps open docs" "$(testmark_body a x)" "two"
-testmark_parse c none.md
-expect_fail "and later parses don't collide with them" 'testmark: c: no hunk named "x"' testmark_body c x
+expect_eq "sourcing again keeps open docs" "$(testmark_body s x)" "one"
 expect_eq "sourcing inside a function" "$(
 	"$BASH" -c 'f() { source "$1"; }; f "$1"; testmark_parse d1 a.md; testmark_parse d2 b.md; testmark_body d1 x' _ "$ROOT/lib/testmark.bash"
 )" "one"
 
 # --- errors ---
+testmark_parse r a.md
 expect_fail "unknown handle" 'testmark: unknown handle "nope"' testmark_body nope x
-expect_fail "unknown hunk" 'testmark: a: no hunk named "nope"' testmark_info a nope
+expect_fail "unknown hunk" 'testmark: r: no hunk named "nope"' testmark_info r nope
 expect_fail "invalid handle" 'testmark: invalid handle "a-b": must match [A-Za-z0-9_]+' testmark_parse a-b a.md
 expect_fail "empty handle" 'testmark: invalid handle "": must match [A-Za-z0-9_]+' testmark_parse '' a.md
-expect_fail "directory" 'testmark: .: not a readable file' testmark_parse a .
-expect_fail "parse arity" 'testmark: usage: testmark_parse HANDLE FILE' testmark_parse a
+expect_fail "directory" 'testmark: .: not a readable file' testmark_parse r .
+expect_fail "parse arity" 'testmark: usage: testmark_parse HANDLE FILE' testmark_parse r
 expect_fail "names arity" 'testmark: usage: testmark_names HANDLE' testmark_names
-expect_fail "body arity" 'testmark: usage: testmark_body HANDLE NAME' testmark_body a x y
-expect_fail "info arity" 'testmark: usage: testmark_info HANDLE NAME' testmark_info a
+expect_fail "body arity" 'testmark: usage: testmark_body HANDLE NAME' testmark_body r x y
+expect_fail "info arity" 'testmark: usage: testmark_info HANDLE NAME' testmark_info r
 
-# --- hostile hunk names: plain data, never evaluated ---
+# --- names with shell syntax: plain data, never evaluated ---
 names=('$(>pwned1)' '`>pwned2`' 'a]b' 'x[0]' '@' '*' '$HOME' "'q'" '"q"' 'a\b')
 for name in "${names[@]}"; do
 	printf '[testmark]:# (%s)\n```\nbody of %s\n```\n\n' "$name" "$name"
-done >hostile.md
-testmark_parse h hostile.md
-testmark_parse other a.md # parsing another handle rebuilds the per-hunk arrays, copying every hostile key
+done >syntax.md
+testmark_parse h syntax.md
 mapfile -t got < <(testmark_names h)
-expect_eq "hostile names list" "$(printf '%s\n' "${got[@]}")" "$(printf '%s\n' "${names[@]}")"
+expect_eq "names with shell syntax: list" "$(printf '%s\n' "${got[@]}")" "$(printf '%s\n' "${names[@]}")"
 for name in "${names[@]}"; do
-	expect_eq "hostile name $name" "$(testmark_body h "$name")" "body of $name"
+	expect_eq "name $name" "$(testmark_body h "$name")" "body of $name"
 done
-expect_fail "a hostile unknown name" 'testmark: h: no hunk named "$(>pwned3)"' testmark_body h '$(>pwned3)'
+expect_fail "an unknown name with shell syntax" 'testmark: h: no hunk named "$(>pwned3)"' testmark_body h '$(>pwned3)'
+expect_fail "a handle with shell syntax" 'testmark: invalid handle "h[$(>pwned4)]": must match [A-Za-z0-9_]+' testmark_body 'h[$(>pwned4)]' x
 expect_eq "nothing was executed" "$(echo pwned*)" "pwned*"
 
 exit "$RESULT"
diff --git a/tests/test-hostile-env.sh b/tests/test-caller-env.sh
similarity index 89%
rename from tests/test-hostile-env.sh
rename to tests/test-caller-env.sh
index 6e8b7df..1d6e317 100755
--- a/tests/test-hostile-env.sh
+++ b/tests/test-caller-env.sh
@@ -13,13 +13,6 @@ shopt -s nocasematch failglob extglob
 IFS='(#) '
 export LC_ALL=C.UTF-8
 
-## Like test-parse.sh's parse_case.
-parse_case() {
-	printf -- "$2" >in.md
-	dump_parse in.md >got
-	expect_eq "$1" "$(<got)" "$(printf '%s\n' "${@:3}")"
-}
-
 parse_case "nocasematch: annotations are case-sensitive" '[TESTMARK]:# (x)\n```\nhello\n```\n'
 parse_case "UTF-8 locale: an em space isn't whitespace" \
 	'[testmark]:# (a\xe2\x80\x83b)\n```\xe2\x80\x83json\xe2\x80\x83\nhello\n```\n' \
diff --git a/tests/test-parse.sh b/tests/test-parse.sh
index 5790d80..d4c43b8 100755
--- a/tests/test-parse.sh
+++ b/tests/test-parse.sh
@@ -10,15 +10,6 @@ source ./lib.sh
 MAL='malformed annotation; want "[testmark]:# (name)"'
 DANGLING='annotation not followed by a code block'
 
-## - @param $1 case name
-## - @param $2 document, as a printf format
-## - @param $3... expected dump lines
-parse_case() {
-	printf -- "$2" >in.md
-	dump_parse in.md >got # not in $(...), so a library bug that kills the shell kills this script too
-	expect_eq "$1" "$(<got)" "$(printf '%s\n' "${@:3}")"
-}
-
 # --- annotation line shape ---
 parse_case ann-basic 'prose\n\n[testmark]:# (x)\n```text\nhello\n```\n' 'hunk "x"' 'info "text"' 'body "hello\n"'
 parse_case ann-trailing-text '[testmark]:# (x reserved words)\n```\nhello\n```\n' 'hunk "x"' 'info ""' 'body "hello\n"'