testmark-bash

test-patch.sh

#!/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 crlf-body "$DOC" 'prose\n\n[testmark]:# (x)\n```text\na\nb\n```\n\nafter\n' 'a\r\nb' 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: $work/two.md:1: 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: $work/r.md: 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: $work/r.md:1: hunk \"x\": $FENCE" testmark_patch r x < <(printf '```\nx\n')
expect_fail "a fence line later" "testmark: $work/r.md:1: hunk \"x\": $FENCE" testmark_patch r x < <(printf 'x\n```json\n')
expect_fail "a fence line without its newline" "testmark: $work/r.md:1: hunk \"x\": $FENCE" testmark_patch r x < <(printf 'x\n```')
expect_fail "a four-backtick fence line" "testmark: $work/r.md:1: hunk \"x\": $FENCE" testmark_patch r x < <(printf '````\n')
expect_fail "info: a newline" "testmark: $work/r.md:1: hunk \"x\": $INFO" testmark_patch r x $'a\nb' <<<'x'
expect_fail "info: a final newline" "testmark: $work/r.md:1: hunk \"x\": $INFO" testmark_patch r x $'a\n' <<<'x'
expect_fail "info: leading space" "testmark: $work/r.md:1: hunk \"x\": $INFO" testmark_patch r x ' a' <<<'x'
expect_fail "info: trailing tab" "testmark: $work/r.md:1: hunk \"x\": $INFO" testmark_patch r x $'a\t' <<<'x'
expect_fail "a NUL byte" "testmark: $work/r.md:1: 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"

tests/test-patch.sh · 10.2 KiB · 60745a01d9