patch.bash
## 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`, normalized the way parsing normalizes a hunk's body:
## CRLF becomes LF, and a final newline is added if it's missing.
## So input reads as the body it would become, and a check compares like with like.
##
## - @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
[[ $input != *$'\r\n'* ]] || input=${input//$'\r\n'/$'\n'}
[[ -z $input || $input == *$'\n' ]] || input+=$'\n'
}
## Stages a new body for hunk $2 of document $1, which must both exist, replacing anything staged for it before.
##
## - @param $3 the body: empty, or ending in a newline
## - @param $4 optional: the new opener line; without it, the hunk's own opener is kept
_testmark_stage() {
local body=$3 opener=${4-}
# 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"
}
lib/testmark/patch.bash · 4.0 KiB · dd9793a266