Patch layer: testmark_patch stages a new body, testmark_write writes it in place.
commit af11966ce403d428225b47900e3154e9b4218c96 · parent d30f313568
· browse this tree
Ximianth, 2026-10-24 00:19:29 UTC
New state is more kinds in _testmark_kinds, so a parse's whole-unset clears it, and the existing globals rows cover it: - file: the parsed path, made absolute, so a cd before the write can't move it. - patch: per hunk, the staged opener line and body, as one string. Storing the opener too means staging again replaces all of it, info string included, with no single element ever unset. - checked: for testmark_check, in the next commit. testmark_write copies the lines, puts each staged string in its hunk's opener slot, empties the old body lines, and writes the result once with >|. Then it re-parses the file into the same handle, which drops everything staged by the same unset as any parse. Slicing out the lines between hunks was the first version: a slice costs the size of the whole array, wherever it starts, so a 20k-line doc with 2000 staged hunks took 5 s to write on every bash. This way it's about one parse. Refused when staging, so a bad patch never reaches the file: a body line starting with ``` (it would close the block early), an info string with a newline or edge whitespace (it wouldn't read back), and a NUL on stdin. Patch and write refuse to run in a subshell, where staged state is lost.
diff --git a/lib/testmark.bash b/lib/testmark.bash index f5789b5..e8e7c5e 100644 --- a/lib/testmark.bash +++ b/lib/testmark.bash @@ -1,9 +1,9 @@ -## testmark.bash: read testmark documents (data hunks embedded in markdown) from bash. +## testmark.bash: read and patch testmark documents (data hunks embedded in markdown) from bash. ## ## Source this file, by a path containing a slash; it sources the rest of the library from beside itself. ## Needs bash 4.4 or later; on older ones, sourcing fails. ## Every public function is a shim over a private `_testmark_*` implementation, run through `_testmark_call`. -## Data comes out on stdout as exact bytes; errors go to stderr as "testmark: ...", with a nonzero status. +## Data comes out on stdout as exact bytes, and goes in on stdin; errors go to stderr as "testmark: ...", with a nonzero status. ## Documents are addressed by a handle the caller picks at parse time; handles match `[A-Za-z0-9_]+`. # Checked first, in syntax bash 3.2 (macOS's /bin/bash) still runs, so an old bash gets one clear error. @@ -14,6 +14,8 @@ fi # shellcheck source=lib/testmark/document.bash source "${BASH_SOURCE[0]%/*}/testmark/document.bash" +# shellcheck source=lib/testmark/patch.bash +source "${BASH_SOURCE[0]%/*}/testmark/patch.bash" ## Runs an implementation function in a known environment, whatever the caller's settings. ## @@ -51,3 +53,20 @@ testmark_body() { _testmark_call _testmark_body "$@"; } ## - @param $2 hunk name ## - @stdout the hunk's info string (the opening fence's text after the backticks, trimmed), then a newline. testmark_info() { _testmark_call _testmark_info "$@"; } + +# The functions below change a document's state, so they refuse to run in a subshell, where the change would be lost: +# feed them input with `testmark_check doc x < <(cmd)`, not `cmd | testmark_check doc x`, and don't call them inside `$(...)`. + +## Stages a new body for a hunk, for testmark_write to write. Staging the same hunk again replaces what was staged before. +## +## - @param $1 handle +## - @param $2 hunk name +## - @param $3 optional: a new info string; the opening fence line becomes "```" and it. Without it, that line is kept as it is. +## - @stdin the new body, exactly; a final newline is added if it lacks one. No line of it may start with "```". +testmark_patch() { _testmark_call _testmark_patch "$@"; } + +## Writes what's staged into the parsed file, in place, then parses the file again under the same handle. +## That drops everything staged and every record of what was checked. With nothing staged, it does nothing. +## +## - @param $1 handle +testmark_write() { _testmark_call _testmark_write "$@"; } diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash index 2fe6e98..396bda8 100644 --- a/lib/testmark/document.bash +++ b/lib/testmark/document.bash @@ -8,10 +8,13 @@ # 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 +# file the file's absolute path, so a `cd` after parse can't send testmark_write elsewhere +# patch assoc by hunk name: what testmark_write puts in place of the opener and body lines, [start - 1, end) +# checked assoc by hunk name: the output testmark_check first saw, so a second, different one is an error # 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, so name-keyed elements are touched only as "Using assoc arrays safely" in docs/hacking/bash-pitfalls.md allows. -_testmark_kinds=(lines start end info names pid) +_testmark_kinds=(lines start end info names pid file patch checked) _testmark_err() { >&2 printf 'testmark: %s\n' "$1" @@ -106,12 +109,16 @@ _testmark_parse() { # Parsed without error; only now are globals written. # Assoc kinds are declared first; see "assoc arrays must be declared as such" in docs/hacking/bash-pitfalls.md. - declare -gA "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h" + declare -gA "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h" "_testmark_patch_$h" "_testmark_checked_$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 + local -n g_names=_testmark_names_$h g_pid=_testmark_pid_$h g_file=_testmark_file_$h g_patch=_testmark_patch_$h g_checked=_testmark_checked_$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 + g_file=$file + [[ $file == /* ]] || g_file=$PWD/$file + # Assigned, not just declared: set -u counts a declared but never assigned array as unset. + g_patch=() g_checked=() } ## Fails with a message unless $1 is the handle of a parsed document. @@ -149,10 +156,17 @@ _testmark_body() { return 1 } _testmark_hunk "$1" "$2" || return 1 + local body + _testmark_get_body "$1" "$2" + printf '%s' "$body" +} + +## Sets the caller's local `body` to the body of hunk $2 in document $1, which must both exist. +_testmark_get_body() { local -n lines=_testmark_lines_$1 start=_testmark_start_$1 end=_testmark_end_$1 - local i=${start[$2]} last=${end[$2]} body= + local i=${start[$2]} last=${end[$2]} + body= for (( ; i < last; i++)); do body+=${lines[i]}; done # not a slice; see "Quadratic operations" in docs/hacking/bash-pitfalls.md - printf '%s' "$body" } _testmark_info() { diff --git a/lib/testmark/patch.bash b/lib/testmark/patch.bash new file mode 100644 index 0000000..f3b40fb --- /dev/null +++ b/lib/testmark/patch.bash @@ -0,0 +1,91 @@ +## patch.bash: staging new hunk bodies, and writing them into the document's file. +## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`. +# shellcheck disable=SC2034,SC2178 # shellcheck does not follow namerefs; see document.bash + +## Fails with a message if this is a subshell of the shell that parsed document $1, which must exist. +## Whatever a subshell stages is lost when it exits, so a regen there would vanish without a word. +_testmark_same_shell() { + local -n pid=_testmark_pid_$1 + [[ $pid == "$BASHPID" ]] || { + _testmark_err "$1: called in a subshell (from a pipe, or inside \$(...)), where any change to the document is lost; instead of 'cmd | testmark_check HANDLE NAME', use 'testmark_check HANDLE NAME < <(cmd)'" + return 1 + } +} + +## Reads all of stdin into the caller's local `input`. +## +## - @param $1 what the input is for, as a prefix for the error message +_testmark_input() { + # Status 0 means read stopped at a NUL; see "NUL bytes vanish unless you check for them" in docs/hacking/bash-pitfalls.md. + if IFS= read -r -d '' input; then + _testmark_err "$1: input contains a NUL byte" + return 1 + fi +} + +## Stages a new body for hunk $2 of document $1, which must both exist, replacing anything staged for it before. +## +## - @param $3 the body; a final newline is added if it lacks one +## - @param $4 optional: the new opener line; without it, the hunk's own opener is kept +_testmark_stage() { + local body=$3 opener=${4-} + [[ -z $body || $body == *$'\n' ]] || body+=$'\n' + # Any line starting with ``` closes the code block, so such a body would end its hunk early and corrupt the rest of the doc. + [[ $'\n'$body != *$'\n```'* ]] || { + _testmark_err "$1: hunk \"$2\": body has a line starting with \`\`\`, which would end its code block" + return 1 + } + local -n lines=_testmark_lines_$1 start=_testmark_start_$1 patch=_testmark_patch_$1 + local i=${start[$2]} + [[ -n $opener ]] || opener=${lines[i - 1]} + patch[$2]=$opener$body +} + +_testmark_patch() { + (($# == 2 || $# == 3)) || { + _testmark_err "usage: testmark_patch HANDLE NAME [INFO] <BODY" + return 1 + } + _testmark_hunk "$1" "$2" || return 1 + _testmark_same_shell "$1" || return 1 + local input opener='' + if (($# == 3)); then + # Parse trims info strings, and a newline would end the opener line, so neither would read back as given. + [[ $3 != *$'\n'* && $3 != [[:space:]]* && $3 != *[[:space:]] ]] || { + _testmark_err "$1: hunk \"$2\": info string must be one line, without leading or trailing whitespace" + return 1 + } + opener='```'$3$'\n' + fi + _testmark_input "$1: hunk \"$2\"" || return 1 + _testmark_stage "$1" "$2" "$input" "$opener" +} + +_testmark_write() { + (($# == 1)) || { + _testmark_err "usage: testmark_write HANDLE" + return 1 + } + _testmark_doc "$1" || return 1 + _testmark_same_shell "$1" || return 1 + local -n patch=_testmark_patch_$1 + ((${#patch[@]} > 0)) || return 0 + + # The output is a copy of the lines, where each staged hunk's opener line holds its staged opener and body, and its old body lines are emptied. + # Slicing out the lines between hunks instead would be quadratic; see "Quadratic operations" in docs/hacking/bash-pitfalls.md. + local -n lines=_testmark_lines_$1 start=_testmark_start_$1 end=_testmark_end_$1 file=_testmark_file_$1 + local -a newlines=("${lines[@]}") + local name i last + for name in "${!patch[@]}"; do + i=${start[$name]} last=${end[$name]} + newlines[i - 1]=${patch[$name]} + for (( ; i < last; i++)); do newlines[i]=; done + done + # In place, so symlinks are followed and the mode is kept; >| so the caller's noclobber doesn't refuse it. + printf '%s' "${newlines[@]}" 2>/dev/null >|"$file" || { + _testmark_err "$file: not writable" + return 1 + } + # Re-parsing drops what was staged and checked, along with the rest of the old state, and proves the output parses. + _testmark_parse "$1" "$file" +} diff --git a/tests/lib.sh b/tests/lib.sh index 0e8aebf..57b9371 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -99,6 +99,14 @@ esc() { printf '"%s"' "$out" } +## - @param $1 file +## - @stdout the file's bytes, exactly, quoted by `esc` +bytes() { + local s + IFS= read -r -d '' s <"$1" || : # 1 is end of file; the test files hold no NUL + esc "$s" +} + ## Prints every hunk of a parsed document: `hunk`, `info`, and `body` lines, values quoted by `esc`. ## ## - @param $1 handle diff --git a/tests/test-patch.sh b/tests/test-patch.sh new file mode 100755 index 0000000..15ef504 --- /dev/null +++ b/tests/test-patch.sh @@ -0,0 +1,192 @@ +#!/bin/bash +## Patching and writing: the written file's bytes, exactly, and every refusal. +set -euo pipefail +cd "$(dirname "$0")" +# shellcheck source=tests/lib.sh +source ./lib.sh + +## Writes a document, stages one patch, writes it, and checks the file's bytes, and that nothing went to stderr. +## +## - @param $1 case name +## - @param $2 document, as a printf format +## - @param $3 expected document after the write, as a printf format +## - @param $4 the new body, as a printf format +## - @param $5... testmark_patch's arguments after the handle: hunk name, and maybe an info string +patch_case() { + local want + printf -- "$2" >in.md + testmark_parse p in.md + # An error shows in the compared value, rather than ending the script; the bytes written so far are still worth seeing. + { + testmark_patch p "${@:5}" < <(printf -- "$4") + testmark_write p + } 2>"$work/err" || : + printf -v want -- "$3" + expect_eq "$1" "$(bytes in.md)$(<"$work/err")" "$(esc "$want")" +} + +DOC='prose\n\n[testmark]:# (x)\n```text\nold\n```\n\nafter\n' + +# --- bodies: only the lines between the fences change --- +patch_case basic "$DOC" 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\n\nafter\n' 'new\n' x +patch_case same-body "$DOC" "$DOC" 'old\n' x +patch_case multi-line "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na\n\nb\n```\n\nafter\n' 'a\n\nb\n' x +patch_case no-final-newline "$DOC" 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\n\nafter\n' 'new' x +patch_case empty "$DOC" 'prose\n\n[testmark]:# (x)\n```text\n```\n\nafter\n' '' x +patch_case one-blank-line-diverges "$DOC" 'prose\n\n[testmark]:# (x)\n```text\n\n```\n\nafter\n' '\n' x +patch_case fill-empty '[testmark]:# (x)\n```\n```\n' '[testmark]:# (x)\n```\nnew\n```\n' 'new\n' x +patch_case lone-cr "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na\rb\n```\n\nafter\n' 'a\rb\n' x +patch_case backticks-not-at-line-start "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na ```\n ```\n```\n\nafter\n' 'a ```\n ```\n' x +patch_case one-of-three '[testmark]:# (a)\n```\none\n```\n[testmark]:# (b)\n```\ntwo\n```\n[testmark]:# (c)\n```\nthree\n```\n' \ + '[testmark]:# (a)\n```\none\n```\n[testmark]:# (b)\n```\nnew\n```\n[testmark]:# (c)\n```\nthree\n```\n' 'new\n' b + +# --- annotation, opener, and closer lines pass through as they are, unless given an info string --- +patch_case info-change "$DOC" 'prose\n\n[testmark]:# (x)\n```json\nnew\n```\n\nafter\n' 'new\n' x json +patch_case info-clear "$DOC" 'prose\n\n[testmark]:# (x)\n```\nnew\n```\n\nafter\n' 'new\n' x '' +patch_case info-words "$DOC" 'prose\n\n[testmark]:# (x)\n```a b\nnew\n```\n\nafter\n' 'new\n' x 'a b' +patch_case ann-trailing-text '[testmark]:# (x more words)\n```\nold\n```\n' '[testmark]:# (x more words)\n```\nnew\n```\n' 'new\n' x +patch_case opener-spacing-kept '[testmark]:# (x)\n``` text \t\nold\n```\n' '[testmark]:# (x)\n``` text \t\nnew\n```\n' 'new\n' x +patch_case closer-trailing-text '[testmark]:# (x)\n```\nold\n``` trailing\n' '[testmark]:# (x)\n```\nnew\n``` trailing\n' 'new\n' x +patch_case four-backticks '[testmark]:# (x)\n````md\nold\n````\n' '[testmark]:# (x)\n````md\nnew\n````\n' 'new\n' x +patch_case four-backticks-info-change '[testmark]:# (x)\n````md\nold\n````\n' '[testmark]:# (x)\n```json\nnew\n````\n' 'new\n' x json + +# --- line endings: CRLF is written back as LF, and a missing final newline stays missing --- +patch_case crlf-whole-doc 'prose\r\n\r\n[testmark]:# (x)\r\n```text\r\nold\r\n```\r\nafter\r\n' 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\nafter\n' 'new\n' x +patch_case doc-no-final-newline '[testmark]:# (x)\n```\nold\n```' '[testmark]:# (x)\n```\nnew\n```' 'new\n' x +patch_case doc-no-final-newline-prose "${DOC%\\n}" 'prose\n\n[testmark]:# (x)\n```text\nnew\n```\n\nafter' 'new\n' x + +# --- several patches, and staging again --- +printf '[testmark]:# (a)\n```\none\n```\n\n[testmark]:# (b)\n```text\ntwo\n```\n' >two.md +## - @param $1 expected two.md, as a printf format +## - @stdout two.md's bytes, then the expected bytes, each quoted by `esc` on a line of its own +two_md() { + local want + printf -v want -- "$1" + printf '%s\n%s\n' "$(bytes two.md)" "$(esc "$want")" +} +cp two.md orig.md +testmark_parse t two.md +testmark_patch t b <<<'new b' +testmark_patch t a <<<'new a' +testmark_write t +mapfile -t got < <(two_md '[testmark]:# (a)\n```\nnew a\n```\n\n[testmark]:# (b)\n```text\nnew b\n```\n') +expect_eq "two patches, staged in reverse order" "${got[0]}" "${got[1]}" +expect_eq "after a write, the handle reads the written file" "$(testmark_body t a)" "new a" + +cp orig.md two.md +testmark_parse t two.md +testmark_patch t a <<<'first' +testmark_patch t a <<<'second' +testmark_patch t b json <<<'first' +testmark_patch t b <<<'second' +testmark_write t +mapfile -t got < <(two_md '[testmark]:# (a)\n```\nsecond\n```\n\n[testmark]:# (b)\n```text\nsecond\n```\n') +expect_eq "staging again replaces all of what was staged, info string included" "${got[0]}" "${got[1]}" + +cp orig.md two.md +testmark_parse t two.md +testmark_patch t a <<<'staged' +expect_fail "a refused patch" 'testmark: t: hunk "a": body has a line starting with ```, which would end its code block' testmark_patch t a <<<'```' +testmark_write t +mapfile -t got < <(two_md '[testmark]:# (a)\n```\nstaged\n```\n\n[testmark]:# (b)\n```text\ntwo\n```\n') +expect_eq "leaves what was staged before" "${got[0]}" "${got[1]}" + +# --- what a write touches, and what it leaves behind --- +printf '[testmark]:# (x)\n```\nold\n```\n' >w.md +testmark_parse w w.md +rm w.md +mkdir w.md +expect_ok "a write with nothing staged" testmark_write w +expect_eq "doesn't open the file" "$([[ -d w.md ]] && echo dir)" dir +testmark_patch w x <<<'new' +expect_fail "a write that can't open the file" "testmark: $work/w.md: not writable" testmark_write w +rmdir w.md + +mkdir sub +printf '[testmark]:# (x)\n```\nold\n```\n' >sub/cd.md +cd sub +testmark_parse c cd.md +cd .. +testmark_patch c x <<<'new' +testmark_write c +expect_eq "a cd between parse and write: writes the parsed file" "$(bytes sub/cd.md)" "$(esc $'[testmark]:# (x)\n```\nnew\n```\n')" +expect_eq "and nothing else" "$([[ -e cd.md ]] || echo absent)" absent + +printf '[testmark]:# (x)\n```\nold\n```\n' >real.md +chmod 640 real.md +ln -s real.md link.md +testmark_parse l link.md +testmark_patch l x <<<'new' +testmark_write l +expect_eq "a symlink: is kept" "$([[ -L link.md ]] && echo link)" link +expect_eq "and its target written, in place" "$(bytes real.md) $(stat -c %a real.md)" "$(esc $'[testmark]:# (x)\n```\nnew\n```\n') 640" + +## - @stdout `declare -p` of every `_testmark_*` global +globals() { + local vars + mapfile -t vars < <(compgen -v _testmark_) + declare -p "${vars[@]}" +} +printf '[testmark]:# (x)\n```\nold\n```\n[testmark]:# (y)\n```\nold\n```\n' >g.md +testmark_parse g g.md +testmark_patch g x json <<<'new' +testmark_write g +after_write=$(globals) +testmark_parse g g.md +expect_eq "a write leaves the same state as a fresh parse of what it wrote" "$after_write" "$(globals)" + +# --- refusals --- +printf '[testmark]:# (x)\n```\nold\n```\n' >r.md +testmark_parse r r.md +FENCE='body has a line starting with ```, which would end its code block' +INFO='info string must be one line, without leading or trailing whitespace' +expect_fail "unknown hunk" 'testmark: r: no hunk named "nope"' testmark_patch r nope <<<'x' +expect_fail "unknown handle" 'testmark: unknown handle "nope"' testmark_patch nope x <<<'x' +expect_fail "write: unknown handle" 'testmark: unknown handle "nope"' testmark_write nope +expect_fail "a fence line first" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf '```\nx\n') +expect_fail "a fence line later" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf 'x\n```json\n') +expect_fail "a fence line without its newline" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf 'x\n```') +expect_fail "a four-backtick fence line" "testmark: r: hunk \"x\": $FENCE" testmark_patch r x < <(printf '````\n') +expect_fail "info: a newline" "testmark: r: hunk \"x\": $INFO" testmark_patch r x $'a\nb' <<<'x' +expect_fail "info: a final newline" "testmark: r: hunk \"x\": $INFO" testmark_patch r x $'a\n' <<<'x' +expect_fail "info: leading space" "testmark: r: hunk \"x\": $INFO" testmark_patch r x ' a' <<<'x' +expect_fail "info: trailing tab" "testmark: r: hunk \"x\": $INFO" testmark_patch r x $'a\t' <<<'x' +expect_fail "a NUL byte" 'testmark: r: hunk "x": input contains a NUL byte' testmark_patch r x < <(printf 'a\0b\n') +expect_fail "patch arity, too few" 'testmark: usage: testmark_patch HANDLE NAME [INFO] <BODY' testmark_patch r <<<'x' +expect_fail "patch arity, too many" 'testmark: usage: testmark_patch HANDLE NAME [INFO] <BODY' testmark_patch r x json more <<<'x' +expect_fail "write arity" 'testmark: usage: testmark_write HANDLE' testmark_write r r +expect_ok "after all that" testmark_write r +expect_eq "nothing was staged" "$(bytes r.md)" "$(esc $'[testmark]:# (x)\n```\nold\n```\n')" + +# --- the subshell guard --- +SUBSHELL="testmark: r: called in a subshell (from a pipe, or inside \$(...)), where any change to the document is lost; instead of 'cmd | testmark_check HANDLE NAME', use 'testmark_check HANDLE NAME < <(cmd)'" +patch_from_pipe() { printf 'new\n' | testmark_patch r x; } +patch_in_subst() { + local got + got=$(testmark_patch r x <<<'new') +} +write_in_subst() { + local got + got=$(testmark_write r) +} +expect_fail "patch from a pipe" "$SUBSHELL" patch_from_pipe +expect_fail "patch inside \$(...)" "$SUBSHELL" patch_in_subst +testmark_patch r x <<<'new' +expect_fail "write inside \$(...)" "$SUBSHELL" write_in_subst +expect_eq "leaves the file alone" "$(bytes r.md)" "$(esc $'[testmark]:# (x)\n```\nold\n```\n')" +expect_ok "patch with < <(...)" testmark_patch r x < <(printf 'new\n') + +# --- names with shell syntax: plain data through staging and writing too --- +names=('$(>pwned1)' '`>pwned2`' 'a]b' 'x[0]' '@' '*' '$HOME' "'q'" '"q"' 'a\b') +for name in "${names[@]}"; do + printf '[testmark]:# (%s)\n```\nold\n```\n\n' "$name" +done >syntax.md +testmark_parse h syntax.md +for name in "${names[@]}"; do testmark_patch h "$name" <<<"new $name"; done +testmark_write h +for name in "${names[@]}"; do + expect_eq "patched $name" "$(testmark_body h "$name")" "new $name" +done +expect_eq "nothing was executed" "$(echo pwned*)" "pwned*" + +exit "$RESULT"