Merge branch 'patch-check': testmark_patch, testmark_write, and testmark_check with TESTMARK_REGEN.
commit c972b45c76f26c2cdcd56c4ec47562df71399a71 · parent 4e822d05d7 · parent 19923c2a6a
· browse this tree
Ximianth, 2026-10-24 01:09:52 UTC
diff --git a/lib/testmark.bash b/lib/testmark.bash index f5789b5..050a507 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, and check output against them. ## ## 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,10 @@ 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" +# shellcheck source=lib/testmark/check.bash +source "${BASH_SOURCE[0]%/*}/testmark/check.bash" ## Runs an implementation function in a known environment, whatever the caller's settings. ## @@ -51,3 +55,31 @@ 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 "$@"; } + +## Checks output against a hunk's body, exactly. +## With TESTMARK_REGEN set, to any value, a mismatch stages the output as the hunk's new body instead, for testmark_write. +## In both modes, checking one hunk again with different output is an error. +## +## - @param $1 handle +## - @param $2 hunk name +## - @stdin the output: empty, or lines each ending in a newline, without CRLF. +## - @return 0 if it matches, or if TESTMARK_REGEN staged it; 1 on a mismatch, or any error. +## - @stderr on a mismatch, a line saying so, then a `diff -u` of the expected and actual output. +testmark_check() { _testmark_call _testmark_check "$@"; } diff --git a/lib/testmark/check.bash b/lib/testmark/check.bash new file mode 100644 index 0000000..3d1dcae --- /dev/null +++ b/lib/testmark/check.bash @@ -0,0 +1,40 @@ +## check.bash: checking output against a hunk, or under TESTMARK_REGEN, staging it as the hunk's new body. +## 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 + +_testmark_check() { + (($# == 2)) || { + _testmark_err "usage: testmark_check HANDLE NAME <ACTUAL" + return 1 + } + _testmark_hunk "$1" "$2" || return 1 + _testmark_same_shell "$1" || return 1 + local input body + _testmark_input "$1: hunk \"$2\"" || return 1 + # A body is "" or lines ending in LF, never CRLF, so other output could never match, and regen would rewrite it on every run. + [[ -z $input || ($input == *$'\n' && $input != *$'\r\n'*) ]] || { + _testmark_err "$1: hunk \"$2\": output must be empty or end in a newline, and contain no CRLF; normalize it before checking" + return 1 + } + + # Two checks claiming one hunk with different output is a harness bug, whether or not the first one matched. + # The same output again is compared like the first time, so a mismatch can't pass by repetition. + local -n checked=_testmark_checked_$1 + [[ ${checked[$2]-$input} == "$input" ]] || { + _testmark_err "$1: hunk \"$2\": checked twice, with different output" + return 1 + } + checked[$2]=$input + + _testmark_get_body "$1" "$2" + [[ $input != "$body" ]] || return 0 + if [[ -n ${TESTMARK_REGEN+set} ]]; then + _testmark_stage "$1" "$2" "$input" + return + fi + local -n file=_testmark_file_$1 + _testmark_err "$1: hunk \"$2\": output doesn't match; set TESTMARK_REGEN=1 to accept it" + # diff's status isn't checked: the comparison above has decided already, and diff is only here to show it. + >&2 diff -u --label "$file: $2 (expected)" --label "$file: $2 (actual)" <(printf '%s' "$body") <(printf '%s' "$input") + return 1 +} 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 d87f243..57b9371 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -50,6 +50,31 @@ expect_eq() { fi } +## Runs a command that should fail, and checks it printed exactly the given error and nothing on stdout. +## +## - @param $1 description +## - @param $2 the expected stderr, without its final newline +## - @param $3... the command +expect_fail() { + local rc=0 + "${@:3}" >"$work/out" 2>"$work/err" || rc=$? + if [[ $rc == 0 ]]; then + _fail "$1: succeeded; want a failure" + else + expect_eq "$1" "$(<"$work/err")$(<"$work/out")" "$2" + 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 " +} + ## Quotes bytes the way the conformance harnesses do: printable ASCII as is, escapes for the rest. ## ## - @param $1 bytes @@ -74,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-api.sh b/tests/test-api.sh index d91b3c7..53e1389 100755 --- a/tests/test-api.sh +++ b/tests/test-api.sh @@ -5,31 +5,6 @@ cd "$(dirname "$0")" # shellcheck source=tests/lib.sh source ./lib.sh -## Runs a command that should fail, and checks it printed exactly the given error and nothing on stdout. -## -## - @param $1 description -## - @param $2 the expected stderr, without its final newline -## - @param $3... the command -expect_fail() { - local rc=0 - "${@:3}" >"$work/out" 2>"$work/err" || rc=$? - if [[ $rc == 0 ]]; then - _fail "$1: succeeded; want a failure" - else - expect_eq "$1" "$(<"$work/err")$(<"$work/out")" "$2" - 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 diff --git a/tests/test-caller-env.sh b/tests/test-caller-env.sh index d69f237..10446c3 100755 --- a/tests/test-caller-env.sh +++ b/tests/test-caller-env.sh @@ -26,6 +26,20 @@ expect_eq "errexit: a doc without a final newline" "$(testmark_body doc x)" "hel rc=0 testmark_body doc nope 2>/dev/null || rc=$? expect_eq "errexit: an error returns, to a caller that checks" "$rc" 1 + +# Patch, check, and write, called directly the same way; and write under noclobber, since it writes the file in place. +printf '[testmark]:# (x)\n```\nhello\n```\n[testmark]:# (y)\n```\nhello\n```\n[testmark]:# (z)\n```\nhello\n```\n' >in.md +testmark_parse doc in.md +testmark_check doc x <<<'hello' +rc=0 +testmark_check doc z <<<'HELLO' 2>/dev/null || rc=$? +expect_eq "nocasematch: a check that differs only in case fails" "$rc" 1 +testmark_patch doc x <<<'patched' +TESTMARK_REGEN=1 testmark_check doc y <<<'regen' +set -C +testmark_write doc +set +C +expect_eq "errexit, noclobber: patch, check, and write" "$(testmark_body doc x)$(testmark_body doc y)" "patchedregen" expect_eq "the ERR trap never fired" "$err_trap_fired" 0 # The library turns on set -u for its own code only. diff --git a/tests/test-check.sh b/tests/test-check.sh new file mode 100755 index 0000000..9c709bf --- /dev/null +++ b/tests/test-check.sh @@ -0,0 +1,151 @@ +#!/bin/bash +## Checking output against hunks, and regenerating them under TESTMARK_REGEN. +## These assert in plain bash, not through testmark_check itself, so a broken check layer can't pass its own tests. +set -euo pipefail +cd "$(dirname "$0")" +# shellcheck source=tests/lib.sh +source ./lib.sh + +DOC='[testmark]:# (a)\n```text\nalpha\n```\n\n[testmark]:# (empty)\n```\n```\n\n[testmark]:# (blank)\n```\n\n```\n\n[testmark]:# (glob)\n```\n*\n```\n' +printf -- "$DOC" >c.md +unset TESTMARK_REGEN + +## Runs a command in this shell, with TESTMARK_REGEN set. +env_regen() { TESTMARK_REGEN=1 "$@"; } + +## Parses c.md afresh as handle c, checks one output against one hunk, and checks the status. +## +## - @param $1 description +## - @param $2 hunk name +## - @param $3 the output, as a printf format +## - @param $4 the expected status +check_case() { + local rc=0 + testmark_parse c c.md + testmark_check c "$2" < <(printf -- "$3") 2>"$work/err" || rc=$? + expect_eq "$1" "$rc" "$4" +} + +# --- normal mode: exact comparison --- +check_case "match" a 'alpha\n' 0 +check_case "a wrong expectation fails" a 'omega\n' 1 +check_case "only the case differs" a 'ALPHA\n' 1 +check_case "one newline more" a 'alpha\n\n' 1 +check_case "empty hunk, empty output" empty '' 0 +check_case "empty hunk, one newline" empty '\n' 1 +check_case "one-blank-line hunk, one newline (diverges)" blank '\n' 0 +check_case "one-blank-line hunk, empty output" blank '' 1 +check_case "a body of glob characters matches only itself" glob 'x\n' 1 +check_case "and itself" glob '*\n' 0 + +testmark_parse c c.md +expect_fail "a mismatch shows a diff" "$( + cat <<-EOF + testmark: c: hunk "a": output doesn't match; set TESTMARK_REGEN=1 to accept it + --- $work/c.md: a (expected) + +++ $work/c.md: a (actual) + @@ -1 +1,2 @@ + -alpha + +omega + +two + EOF +)" testmark_check c a < <(printf 'omega\ntwo\n') +expect_ok "and stages nothing" testmark_write c +expect_eq "so the file is unchanged" "$(bytes c.md)" "$(esc "$(printf -- "$DOC")"$'\n')" + +# --- regen mode --- +testmark_parse c c.md +expect_ok "regen: a mismatch succeeds" env_regen testmark_check c a <<<'omega' +expect_ok "regen: a match succeeds" env_regen testmark_check c blank <<<'' +expect_ok "regen: and the write" testmark_write c +expect_eq "writes only the mismatch, keeping its info string" "$(bytes c.md)" \ + "$(esc "$(printf -- "${DOC/alpha/omega}")"$'\n')" +expect_ok "and then the same output checks clean" testmark_check c a <<<'omega' +printf -- "$DOC" >c.md + +testmark_parse c c.md +TESTMARK_REGEN='' expect_ok "regen: TESTMARK_REGEN set but empty is regen too" testmark_check c a <<<'omega' +testmark_write c +expect_eq "and writes" "$(testmark_body c a)" "omega" +printf -- "$DOC" >c.md + +# --- both modes --- +FENCE='body has a line starting with ```, which would end its code block' +UNHOLDABLE='output must be empty or end in a newline, and contain no CRLF; normalize it before checking' +for mode in normal regen; do + run=() + [[ $mode == normal ]] || run=(env_regen) + testmark_parse c c.md + expect_fail "$mode: absent hunk" 'testmark: c: no hunk named "nope"' "${run[@]}" testmark_check c nope <<<'x' + expect_fail "$mode: no final newline" "testmark: c: hunk \"a\": $UNHOLDABLE" "${run[@]}" testmark_check c a < <(printf 'omega') + expect_fail "$mode: CRLF" "testmark: c: hunk \"a\": $UNHOLDABLE" "${run[@]}" testmark_check c a < <(printf 'omega\r\n') + expect_fail "$mode: a NUL byte" 'testmark: c: hunk "a": input contains a NUL byte' "${run[@]}" testmark_check c a < <(printf 'a\0b\n') + expect_fail "$mode: arity" 'testmark: usage: testmark_check HANDLE NAME <ACTUAL' "${run[@]}" testmark_check c <<<'x' + expect_ok "$mode: none of which staged anything" testmark_write c + expect_eq "$mode: so the file is unchanged" "$(bytes c.md)" "$(esc "$(printf -- "$DOC")"$'\n')" + + # Checking a hunk twice: the same output is checked again like the first time, other output is an error, whether or not the first check matched. + for first in alpha omega; do + testmark_parse c c.md + "${run[@]}" testmark_check c a <<<"$first" 2>/dev/null || : + rc=0 want=0 + "${run[@]}" testmark_check c a <<<"$first" 2>/dev/null || rc=$? + [[ $mode == regen || $first == alpha ]] || want=1 + expect_eq "$mode: first $first, then the same again" "$rc" "$want" + expect_fail "$mode: first $first, then other output" 'testmark: c: hunk "a": checked twice, with different output' "${run[@]}" testmark_check c a <<<'other' + expect_fail "$mode: first $first, then glob characters" 'testmark: c: hunk "a": checked twice, with different output' "${run[@]}" testmark_check c a <<<'*' + done +done + +testmark_parse c c.md +expect_fail "regen: output with a fence line" "testmark: c: hunk \"a\": $FENCE" env_regen testmark_check c a < <(printf 'x\n```\n') + +testmark_parse c c.md +env_regen testmark_check c a <<<'omega' +testmark_write c +expect_fail "a write drops what was checked: other output is a mismatch, not a second check" "$( + cat <<-EOF + testmark: c: hunk "a": output doesn't match; set TESTMARK_REGEN=1 to accept it + --- $work/c.md: a (expected) + +++ $work/c.md: a (actual) + @@ -1 +1 @@ + -omega + +other + EOF +)" testmark_check c a <<<'other' +printf -- "$DOC" >c.md + +# --- the subshell guard, in both modes --- +SUBSHELL="testmark: c: 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)'" +check_from_pipe() { printf 'omega\n' | "$@" testmark_check c a; } +check_in_subst() { + local got + # shellcheck disable=SC2034 # only the call inside $(...) matters, not its output + got=$("$@" testmark_check c a <<<'omega') +} +testmark_parse c c.md +expect_fail "normal: from a pipe" "$SUBSHELL" check_from_pipe +expect_fail "normal: inside \$(...)" "$SUBSHELL" check_in_subst +expect_fail "regen: from a pipe" "$SUBSHELL" check_from_pipe env_regen +expect_fail "regen: inside \$(...)" "$SUBSHELL" check_in_subst env_regen +expect_ok "none of which staged anything" testmark_write c +expect_eq "so the file is unchanged" "$(bytes c.md)" "$(esc "$(printf -- "$DOC")"$'\n')" +expect_ok "regen with < <(...)" env_regen testmark_check c a < <(printf 'omega\n') + +# --- names with shell syntax: plain data through checking and regen --- +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 env_regen testmark_check h "$name" <<<"new $name"; done +for name in "${names[@]}"; do + expect_fail "checked $name twice" "testmark: h: hunk \"$name\": checked twice, with different output" testmark_check h "$name" <<<'other' +done +testmark_write h +for name in "${names[@]}"; do + expect_ok "regenerated $name" testmark_check h "$name" <<<"new $name" +done +expect_eq "nothing was executed" "$(echo pwned*)" "pwned*" + +exit "$RESULT" diff --git a/tests/test-patch.sh b/tests/test-patch.sh new file mode 100755 index 0000000..b0938d7 --- /dev/null +++ b/tests/test-patch.sh @@ -0,0 +1,193 @@ +#!/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_check g y <<<'old' +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"