testmark-bash

check, patch: normalize input as parsing normalizes bodies (CRLF to LF, a final newline added), instead of refusing it.

commit dd9793a26647f879f79fd62d52539ed94352a109 · parent f2bd49de52 · browse this tree
Ximianth, 2026-10-24 15:45:03 UTC

Hunk bodies have always been read that way, so output that differed only in line endings could never match,
and check refused it with a message to normalize first.
That put line endings in every user's face, for a distinction testmark has never kept.
Now check and patch read their input the same way the parser reads a body, and compare like with like.
A lone CR is still content, and CR CR LF still leaves one CR, as in parsing.
diff --git a/lib/testmark.bash b/lib/testmark.bash
index 9ed18c8..7b0a1ba 100644
--- a/lib/testmark.bash
+++ b/lib/testmark.bash
@@ -4,6 +4,7 @@
 ## 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, and goes in on stdin; errors go to stderr as "testmark: ...", with a nonzero status.
+## Data going in is normalized as parsing normalizes hunk bodies: CRLF becomes LF, and a missing final newline is added.
 ## 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.
@@ -69,7 +70,7 @@ testmark_info() { _testmark_call _testmark_info "$@"; }
 ## - @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 "```".
+## - @stdin the new body, normalized as all input is.  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.
@@ -84,7 +85,7 @@ testmark_write() { _testmark_call _testmark_write "$@"; }
 ##
 ## - @param $1 handle
 ## - @param $2 hunk name
-## - @stdin the output: empty, or lines each ending in a newline, without CRLF.
+## - @stdin the output, normalized as all input is, then compared exactly.
 ## - @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 "$@"; }
@@ -101,8 +102,7 @@ testmark_check() { _testmark_call _testmark_check "$@"; }
 ## The case is the hunk's name minus its last `/`-segment; the hunk is its trigger.
 ## testmarktest_run runs FN for each case in a fresh bash, in the script's directory, with the trigger's body on stdin,
 ## and checks what FN prints, stdout and stderr together, against the case's `output` hunk, using testmark_check.
-## Before the check, if FN's output doesn't end in a newline, it gets one, then a line `\ No newline at end of output`;
-## and if FN exits nonzero, a line `exit status: N` is added.
+## If FN exits nonzero, a line `exit status: N` is added to its output before the check.
 ##
 ## FN runs under `set -euo pipefail`.  It sees exported variables, but no other variables, and no functions but itself and those given to testmarktest_provide.
 ##
diff --git a/lib/testmark/check.bash b/lib/testmark/check.bash
index 3d1dcae..5d137b8 100644
--- a/lib/testmark/check.bash
+++ b/lib/testmark/check.bash
@@ -11,12 +11,6 @@ _testmark_check() {
 	_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
diff --git a/lib/testmark/patch.bash b/lib/testmark/patch.bash
index f3b40fb..ea80064 100644
--- a/lib/testmark/patch.bash
+++ b/lib/testmark/patch.bash
@@ -12,7 +12,9 @@ _testmark_same_shell() {
 	}
 }
 
-## Reads all of stdin into the caller's local `input`.
+## 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() {
@@ -21,15 +23,16 @@ _testmark_input() {
 		_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; a final newline is added if it lacks one
+## - @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-}
-	[[ -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"
diff --git a/tests/test-check.sh b/tests/test-check.sh
index 9c709bf..7382760 100755
--- a/tests/test-check.sh
+++ b/tests/test-check.sh
@@ -38,6 +38,13 @@ 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
 
+# --- output is normalized as bodies are, before comparing ---
+check_case "a missing final newline is added" a 'alpha' 0
+check_case "CRLF reads as LF" a 'alpha\r\n' 0
+check_case "a lone CR is content" a 'alpha\r' 1
+check_case "CR CR LF leaves one CR" a 'alpha\r\r\n' 1
+check_case "empty output stays empty" empty '' 0
+
 testmark_parse c c.md
 expect_fail "a mismatch shows a diff" "$(
 	cat <<-EOF
@@ -63,6 +70,12 @@ expect_eq "writes only the mismatch, keeping its info string" "$(bytes c.md)" \
 expect_ok "and then the same output checks clean" testmark_check c a <<<'omega'
 printf -- "$DOC" >c.md
 
+testmark_parse c c.md
+expect_ok "regen: output with CRLF and no final newline" env_regen testmark_check c a < <(printf 'omega\r\ntwo')
+testmark_write c
+expect_eq "is written normalized" "$(bytes c.md)" "$(esc "$(printf -- "${DOC/alpha/omega\\ntwo}")"$'\n')"
+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
@@ -71,14 +84,11 @@ 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
diff --git a/tests/test-patch.sh b/tests/test-patch.sh
index b0938d7..717a575 100755
--- a/tests/test-patch.sh
+++ b/tests/test-patch.sh
@@ -36,6 +36,7 @@ patch_case empty "$DOC" 'prose\n\n[testmark]:# (x)\n```text\n```\n\nafter\n' ''
 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