testmark-bash

Key hunks by handle, and forget a handle's hunks by rebuilding the arrays.

commit 7be6f9532795b8c2cc7053d89dc3cc5e0eb255a2 · parent 421966087a · browse this tree
Ximianth, 2026-10-23 13:21:22 UTC

Re-parsing a handle has to drop the old document's hunks, or the duplicate check trips on them
and names only the old document had still answer.
Unsetting single assoc elements isn't safe for every key, so _testmark_forget copies each
per-hunk array without the handle's keys, then copies it back.

This replaces the per-parse numbering, which avoided the rebuild,
but made the data model harder to explain than it's worth.
A re-parse still leaves the old document's lines in _testmark_lines, unused.
diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash
index 680beaa..e2ecf54 100644
--- a/lib/testmark/document.bash
+++ b/lib/testmark/document.bash
@@ -2,16 +2,14 @@
 ## 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, and gets a number from _testmark_parses.
-# Per-hunk keys are "PARSE:NAME", so a re-parse makes the old entries unreachable, and nothing is ever unset.
+# Each parse appends its lines to _testmark_lines (a re-parse leaves the old ones behind, unused); per-hunk entries are keyed "HANDLE:NAME".
 # 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.
 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_parses=${_testmark_parses-0}
-declare -gA _testmark_doc _testmark_pid _testmark_names # by handle: parse number (empty while unusable), $BASHPID at parse (state changes refuse other pids), names each ending in "\n"
-_testmark_doc+=() _testmark_pid+=() _testmark_names+=()
-declare -gA _testmark_start _testmark_end _testmark_info # by "PARSE:NAME": body line range [start, end) in _testmark_lines, and info string
+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 -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+=()
 
 _testmark_err() {
@@ -28,7 +26,8 @@ _testmark_parse() {
 		_testmark_err "invalid handle \"$h\": must match [A-Za-z0-9_]+"
 		return 1
 	}
-	_testmark_doc[$h]=
+	_testmark_base[$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"
@@ -41,7 +40,6 @@ _testmark_parse() {
 		return 1
 	}
 	end=${#_testmark_lines[@]}
-	local doc=$((_testmark_parses += 1))
 
 	# 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=''
@@ -87,7 +85,7 @@ _testmark_parse() {
 				return 1
 			fi
 			name=${BASH_REMATCH[1]}
-			k=$doc:$name
+			k=$h:$name
 			[[ -z ${_testmark_start[$k]+set} ]] || {
 				_testmark_err "$file:$n: duplicate hunk name \"$name\""
 				return 1
@@ -107,17 +105,30 @@ _testmark_parse() {
 
 	_testmark_names[$h]=$names
 	_testmark_pid[$h]=$BASHPID
-	_testmark_doc[$h]=$doc
+	_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.
+_testmark_forget() {
+	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() {
-	local doc=${_testmark_doc[$1]-}
-	[[ -n $doc ]] || {
+	[[ -n ${_testmark_base[$1]-} ]] || {
 		_testmark_err "unknown handle \"$1\""
 		return 1
 	}
-	k=$doc:$2
+	k=$1:$2
 	[[ -n ${_testmark_start[$k]+set} ]] || {
 		_testmark_err "$1: no hunk named \"$2\""
 		return 1
@@ -129,7 +140,7 @@ _testmark_names() {
 		_testmark_err "usage: testmark_names HANDLE"
 		return 1
 	}
-	[[ -n ${_testmark_doc[$1]-} ]] || {
+	[[ -n ${_testmark_base[$1]-} ]] || {
 		_testmark_err "unknown handle \"$1\""
 		return 1
 	}
diff --git a/tests/test-api.sh b/tests/test-api.sh
index e772921..fa4b474 100755
--- a/tests/test-api.sh
+++ b/tests/test-api.sh
@@ -20,6 +20,16 @@ expect_fail() {
 	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
@@ -44,10 +54,12 @@ testmark_parse e empty.md
 expect_eq "handle a" "$(testmark_body a x)" "one"
 expect_eq "handle b" "$(testmark_body b x)" "two"
 expect_fail "an empty doc doesn't see another doc's hunks" 'testmark: e: no hunk named "x"' testmark_body e x
-testmark_parse a b.md
+expect_ok "re-parse" testmark_parse a b.md
 expect_eq "re-parse replaces the doc" "$(testmark_body a x)" "two"
 expect_fail "re-parse drops the old doc's names" 'testmark: a: no hunk named "old"' testmark_body a old
-expect_eq "re-parse leaves other handles alone" "$(testmark_names b)" "x"
+expect_eq "re-parse leaves other handles alone" "$(testmark_body b x)" "two"
+expect_ok "re-parse of the same file" testmark_parse b b.md
+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
 
@@ -78,6 +90,7 @@ 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
 mapfile -t got < <(testmark_names h)
 expect_eq "hostile names list" "$(printf '%s\n' "${got[@]}")" "$(printf '%s\n' "${names[@]}")"
 for name in "${names[@]}"; do