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"'