Store each document in globals named after its handle; parse into locals and commit once.
commit 0193a638f6dfb737579e034f60694680dbb15fc3 · parent 6117ccc1a3
· browse this tree
Ximianth, 2026-10-23 14:07:03 UTC
Before, all handles shared one lines array (with base, count, and next-free bookkeeping) and per-hunk assoc arrays keyed "HANDLE:NAME", so forgetting a handle meant unsetting a range of lines and rebuilding every per-hunk array without its keys; and parse wrote globals as it went, so a failed parse left state that the next parse of that handle had to clean up. Now each handle has its own `_testmark_KIND_HANDLE` globals, and forgetting it is one unset of whole variables. That's safe because the name is built only from a handle checked against [A-Za-z0-9_]+. Parse builds lines and hunks in function locals and copies them to the handle's globals only at its end, on success, so any error return discards its partial work by going out of scope. Readers build variable names from the handle too, so they now check it against the same pattern first: an unchecked handle like `h[$(cmd)]` would reach `[[ -v ]]` as a subscript. A test passes such a handle to testmark_body. The round-1 white-box tests counted the shared lines array; they're replaced by comparing `declare -p` of every `_testmark_*` global before and after: a late-failing parse, and a re-parse, leave nothing behind. shellcheck 0.9 doesn't follow namerefs, so document.bash disables SC2034 and SC2178 file-wide.
diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash index f44a15a..8f35b95 100644 --- a/lib/testmark/document.bash +++ b/lib/testmark/document.bash @@ -1,21 +1,19 @@ ## 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`: +# 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; state changes refuse other pids +# So forgetting a handle is one `unset` of whole variables, and nothing else needs cleaning up. +# Names are built only from handles checked against [A-Za-z0-9_]+, and kinds have no "_", so two handles' names never meet. +# Parse works in locals and commits at its very end, so a failed parse leaves nothing behind. +# Globals are reached through namerefs; assoc ones are declared first, since a nameref to an undeclared name makes an indexed array. +# Hunk names are hostile input as keys: arithmetic, `[[ -v ]]`, `unset`, and `printf -v` on an element can 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_err() { >&2 printf 'testmark: %s\n' "$1" @@ -31,42 +29,38 @@ _testmark_parse() { _testmark_err "invalid handle \"$h\": must match [A-Za-z0-9_]+" return 1 } - _testmark_pid[$h]= - _testmark_forget "$h" + unset -v "_testmark_lines_$h" "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h" "_testmark_names_$h" "_testmark_pid_$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 } - # 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 +68,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 +101,27 @@ _testmark_parse() { return 1 } - _testmark_names[$h]=$names - _testmark_pid[$h]=$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 + 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 k in "${!start[@]}"; do g_start[$k]=${start[$k]} g_end[$k]=${end[$k]} g_info[$k]=${info[$k]}; done + g_names=$names g_pid=$BASHPID } -## 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() { + [[ $1 =~ ^[A-Za-z0-9_]+$ && -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 +132,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 +142,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 +154,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/test-api.sh b/tests/test-api.sh index 550cae1..03006fd 100755 --- a/tests/test-api.sh +++ b/tests/test-api.sh @@ -63,22 +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 --- +# --- what a parse doesn't keep is gone: white-box, comparing every library global and its value --- 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" "$@" +## - @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: unclosed.md:2: code block not closed' testmark_parse g unclosed.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 --- # shellcheck source=lib/testmark.bash @@ -107,13 +108,13 @@ 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 expect_eq "hostile 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 "a hostile handle" 'testmark: unknown handle "h[$(>pwned4)]"' testmark_body 'h[$(>pwned4)]' x expect_eq "nothing was executed" "$(echo pwned*)" "pwned*" exit "$RESULT"