testmark-bash

Free a handle's lines when it's forgotten, including a failed parse's.

commit 6117ccc1a391a8cb68ca029901ec2243d5836fc5 · parent 703211b0e1 · browse this tree
Ximianth, 2026-10-23 13:37:20 UTC

Re-parsing a handle used to leave the old document's lines in _testmark_lines for good.
Now _testmark_forget unsets them too.
That's safe where unsetting per-hunk entries isn't: line indexes are the library's own numbers,
not hostile names.

Each handle's line range is recorded right after mapfile, so lines read by a parse that then fails
are freed by the handle's next parse.
With holes in the array, its element count no longer gives the next free index,
so _testmark_next tracks it.
The usable-handle marker moves to _testmark_pid, which only a successful parse sets.

Tests count the lines array in a fresh shell after re-parses and failed parses,
and check that a new document doesn't land on a live one's lines.
diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash
index e2ecf54..f44a15a 100644
--- a/lib/testmark/document.bash
+++ b/lib/testmark/document.bash
@@ -2,13 +2,18 @@
 ## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`.
 
 # 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 (a re-parse leaves the old ones behind, unused); per-hunk entries are keyed "HANDLE:NAME".
+# 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 -gA _testmark_base _testmark_pid _testmark_names # by handle: index of its first line (empty while unusable), $BASHPID at parse (state changes refuse other pids), names each ending in "\n"
-_testmark_base+=() _testmark_pid+=() _testmark_names+=()
+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+=()
 
@@ -26,20 +31,21 @@ _testmark_parse() {
 		_testmark_err "invalid handle \"$h\": must match [A-Za-z0-9_]+"
 		return 1
 	}
-	_testmark_base[$h]=
+	_testmark_pid[$h]=
 	_testmark_forget "$h"
 	# 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
 	}
-	# Lines are never unset, so the element count is also the next free index.
-	local base=${#_testmark_lines[@]} end
+	# 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" || {
 		_testmark_err "$file: read failed"
 		return 1
 	}
-	end=${#_testmark_lines[@]}
+	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=''
@@ -105,12 +111,13 @@ _testmark_parse() {
 
 	_testmark_names[$h]=$names
 	_testmark_pid[$h]=$BASHPID
-	_testmark_base[$h]=$base
 }
 
-## Drops handle $1's per-hunk entries, by rebuilding each per-hunk array without them,
-## since unsetting a single assoc element has no spelling that's safe for every key and setting.
+## 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
@@ -124,7 +131,7 @@ _testmark_forget() {
 
 ## 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_base[$1]-} ]] || {
+	[[ -n ${_testmark_pid[$1]-} ]] || {
 		_testmark_err "unknown handle \"$1\""
 		return 1
 	}
@@ -140,7 +147,7 @@ _testmark_names() {
 		_testmark_err "usage: testmark_names HANDLE"
 		return 1
 	}
-	[[ -n ${_testmark_base[$1]-} ]] || {
+	[[ -n ${_testmark_pid[$1]-} ]] || {
 		_testmark_err "unknown handle \"$1\""
 		return 1
 	}
diff --git a/tests/test-api.sh b/tests/test-api.sh
index fa4b474..550cae1 100755
--- a/tests/test-api.sh
+++ b/tests/test-api.sh
@@ -63,6 +63,23 @@ 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" "$@"
+}
+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"
+
 # --- sourcing: twice keeps state; inside a function still makes globals ---
 # shellcheck source=lib/testmark.bash
 source "$ROOT/lib/testmark.bash"