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