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"