testmark-bash

Document layer: parse a testmark doc, and read its names, bodies, and info strings.

commit 53a614c00a2e7ecece908d27538d74213b49fca7 · parent b8251374c0 · browse this tree
Ximianth, 2026-10-23 09:44:22 UTC

lib/testmark.bash is the file callers source.
Each public function is a one-line shim over a private implementation, run through _testmark_call,
which sets LC_ALL=C (bytes in, bytes out, ASCII whitespace), turns nocasematch off and back on,
and runs the implementation in an `||` list so the caller's errexit and ERR trap stay out of it.

Storage is private: one shared array of lines, appended to by each parse,
and assoc arrays keyed by handle, or by "PARSE:NAME" for hunks.
Each parse gets a fresh number, so a re-parse makes its old entries unreachable
instead of unsetting assoc elements, for which no spelling is safe for every key and setting.

Parse follows testmark-rs, except that whitespace is ASCII (SP HT VT FF CR),
NUL is an error, a hunk of one blank line reads as "\n", and there are no table hunks.
Errors are fail-closed and name the file and line.

Tests: tests/run.sh runs each tests/test-*.sh; this one covers the read API,
handles, re-parsing, sourcing twice or from a function, and hostile hunk names.
make check runs shfmt and shellcheck.
diff --git a/Makefile b/Makefile
index 2fd6d68..a8af5cd 100644
--- a/Makefile
+++ b/Makefile
@@ -3,10 +3,8 @@
 SHELL  = /bin/bash
 .SHELLFLAGS = -o pipefail -c
 
-MADE ?= $(HOME)/made
-
 .PHONY: all
-all: fmt build test ## Everything that's a must before committing: fmt, build, test.
+all: fmt test ## Everything that's a must before committing: fmt, test.
 
 .PHONY: help
 help: ## Print info about all commands
@@ -16,29 +14,18 @@ help: ## Print info about all commands
 		| awk 'BEGIN {FS = ":.*?## "}; {printf "    \033[01;32m%-20s\033[0m %s\n", $$1, $$2}'
 
 .PHONY: setup
-setup: ## One-time dev env setup: activate git hooks (and install deps, if any).
+setup: ## One-time dev env setup: activate git hooks.
 	git config core.hooksPath hack/git-hooks
 
 .PHONY: fmt
 fmt: ## Format source in place.
-	$(error PLACEHOLDER: formatter invocation)
+	shfmt -w .
 
 .PHONY: check
 check: ## Verify-only and fast; run by the pre-commit hook on every commit.
-	$(error PLACEHOLDER: format check in verify mode, plus other fast verifications at this project's discretion)
-
-.PHONY: build
-build: ## Build (dev profile).
-	$(error PLACEHOLDER: build invocation)
+	shfmt -d .
+	shellcheck -x -S warning lib/*.bash lib/*/*.bash tests/*.sh
 
 .PHONY: test
 test: ## Run all tests.
-	$(error PLACEHOLDER: test invocation)
-
-.PHONY: clean
-clean: ## Remove regenerable outputs.
-	$(error PLACEHOLDER: removals)
-
-.PHONY: install
-install: ## Install binaries into $(MADE)/PLACEHOLDER/bin/.
-	$(error PLACEHOLDER: build or copy outputs into "$(MADE)/PLACEHOLDER/bin/")
+	tests/run.sh
diff --git a/lib/testmark.bash b/lib/testmark.bash
new file mode 100644
index 0000000..c747d56
--- /dev/null
+++ b/lib/testmark.bash
@@ -0,0 +1,44 @@
+## testmark.bash: read testmark documents (data hunks embedded in markdown) from bash.
+##
+## Source this file, by a path containing a slash; it sources the rest of the library from beside itself.
+## 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.
+## Documents are addressed by a handle the caller picks at parse time; handles match `[A-Za-z0-9_]+`.
+
+# shellcheck source=lib/testmark/document.bash
+source "${BASH_SOURCE[0]%/*}/testmark/document.bash"
+
+## Runs an implementation function in a known environment, whatever the caller's settings.
+##
+## - `LC_ALL=C`: string operations work on bytes, and `[[:space:]]` is exactly ASCII whitespace.
+## - `nocasematch` off: pattern matches are exact.  It's restored afterwards, since `local -` doesn't cover shopts.
+## - The `||` list: inside it, the caller's `set -e` and ERR trap can't fire on the implementation's ordinary nonzero statuses.
+_testmark_call() {
+	local LC_ALL=C _testmark_st=0 _testmark_nocase=
+	case ":$BASHOPTS:" in *:nocasematch:*) _testmark_nocase=1 && shopt -u nocasematch ;; esac
+	"$@" || _testmark_st=$?
+	[[ -z $_testmark_nocase ]] || shopt -s nocasematch
+	return "$_testmark_st"
+}
+
+## Parses FILE and makes it available under HANDLE.
+## Parsing again under the same handle replaces the earlier document; if that parse fails, the handle is left unusable.
+##
+## - @param $1 handle
+## - @param $2 file path; also used in error messages.
+## - @stderr on failure, one line: "testmark: FILE:LINE: problem" for parse errors.
+testmark_parse() { _testmark_call _testmark_parse "$@"; }
+
+## - @param $1 handle
+## - @stdout the document's hunk names, one per line, in document order.
+testmark_names() { _testmark_call _testmark_names "$@"; }
+
+## - @param $1 handle
+## - @param $2 hunk name
+## - @stdout the hunk's body, exactly: its lines, each ending in a newline, or nothing for an empty hunk.
+testmark_body() { _testmark_call _testmark_body "$@"; }
+
+## - @param $1 handle
+## - @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 "$@"; }
diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash
new file mode 100644
index 0000000..680beaa
--- /dev/null
+++ b/lib/testmark/document.bash
@@ -0,0 +1,159 @@
+## document.bash: parsing a testmark document, and reading its hunks.
+## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`.
+
+# Storage: private, and declared `-g` so sourcing from inside a function works; `+=()` makes them set-u safe, and keeps contents if sourced twice.
+# Each parse appends its lines to _testmark_lines, and gets a number from _testmark_parses.
+# Per-hunk keys are "PARSE:NAME", so a re-parse makes the old entries unreachable, and nothing is ever unset.
+# Names are hostile input as keys: touch keyed elements only as `a[$k]=v`, `"${a[$k]}"`, `"${a[$k]-}"`, and `${a[$k]+set}`.
+# In particular never in arithmetic, and never with `unset`: those can run `$(...)` from inside a key.
+declare -ga _testmark_lines # every document's lines, each keeping its "\n" (a file's last line may lack one), CRLF made LF
+_testmark_lines+=()
+declare -g _testmark_parses=${_testmark_parses-0}
+declare -gA _testmark_doc _testmark_pid _testmark_names # by handle: parse number (empty while unusable), $BASHPID at parse (state changes refuse other pids), names each ending in "\n"
+_testmark_doc+=() _testmark_pid+=() _testmark_names+=()
+declare -gA _testmark_start _testmark_end _testmark_info # by "PARSE:NAME": body line range [start, end) in _testmark_lines, and info string
+_testmark_start+=() _testmark_end+=() _testmark_info+=()
+
+_testmark_err() {
+	>&2 printf 'testmark: %s\n' "$1"
+}
+
+_testmark_parse() {
+	(($# == 2)) || {
+		_testmark_err "usage: testmark_parse HANDLE FILE"
+		return 1
+	}
+	local h=$1 file=$2
+	[[ $h =~ ^[A-Za-z0-9_]+$ ]] || {
+		_testmark_err "invalid handle \"$h\": must match [A-Za-z0-9_]+"
+		return 1
+	}
+	_testmark_doc[$h]=
+	# A directory would read as zero lines, so it's rejected here.
+	[[ -f $file && -r $file ]] || {
+		_testmark_err "$file: not a readable file"
+		return 1
+	}
+	# Lines are never unset, so the element count is also the next free index.
+	local base=${#_testmark_lines[@]} end
+	mapfile -O "$base" _testmark_lines <"$file" || {
+		_testmark_err "$file: read failed"
+		return 1
+	}
+	end=${#_testmark_lines[@]}
+	local doc=$((_testmark_parses += 1))
+
+	# State: `open` while inside a code block; `k` from an annotation until its hunk closes; `at` is the line number of the annotation or opener.
+	local i n line name info names='' open='' k='' at=''
+	for ((i = base; i < end; i++)); do
+		line=${_testmark_lines[i]}
+		n=$((i - base + 1))
+		# mapfile ends a line at a NUL, dropping its "\n".  So a line without one is the last line, or has a NUL; read -d '' tells which.
+		if [[ $line != *$'\n' ]] && IFS= read -r -d '' _ <"$file"; then
+			_testmark_err "$file:$n: contains a NUL byte"
+			return 1
+		fi
+		if [[ $line == *$'\r\n' ]]; then
+			line=${line%$'\r\n'}
+			_testmark_lines[i]=$line$'\n'
+		else
+			line=${line%$'\n'}
+		fi
+
+		if [[ -n $open ]]; then
+			if [[ $line == '```'* ]]; then
+				[[ -z $k ]] || _testmark_end[$k]=$i
+				open='' k=''
+			fi
+		elif [[ -n $k ]]; then
+			[[ $line == '```'* ]] || {
+				_testmark_err "$file:$at: annotation not followed by a code block"
+				return 1
+			}
+			info=${line#'```'}
+			# Trim: strip the leading run of whitespace, then the trailing one.
+			info=${info#"${info%%[![:space:]]*}"}
+			info=${info%"${info##*[![:space:]]}"}
+			_testmark_info[$k]=$info
+			_testmark_start[$k]=$((i + 1))
+			open=1 at=$n
+		elif [[ $line == '```'* ]]; then
+			open=1 at=$n
+		elif [[ $line == '[testmark]:#'* ]]; then
+			name=${line#'[testmark]:# ('}
+			name=${name%')'}
+			if [[ $line != '[testmark]:# ('*')' || ! $name =~ ^[[:space:]]*([^[:space:]]+) ]]; then
+				_testmark_err "$file:$n: malformed annotation; want \"[testmark]:# (name)\""
+				return 1
+			fi
+			name=${BASH_REMATCH[1]}
+			k=$doc:$name
+			[[ -z ${_testmark_start[$k]+set} ]] || {
+				_testmark_err "$file:$n: duplicate hunk name \"$name\""
+				return 1
+			}
+			names+=$name$'\n'
+			at=$n
+		fi
+	done
+	if [[ -n $open ]]; then
+		_testmark_err "$file:$at: code block not closed"
+		return 1
+	fi
+	[[ -z $k ]] || {
+		_testmark_err "$file:$at: annotation not followed by a code block"
+		return 1
+	}
+
+	_testmark_names[$h]=$names
+	_testmark_pid[$h]=$BASHPID
+	_testmark_doc[$h]=$doc
+}
+
+## Sets the caller's local `k` to the storage key of hunk $2 in document $1, or fails with a message.
+_testmark_hunk() {
+	local doc=${_testmark_doc[$1]-}
+	[[ -n $doc ]] || {
+		_testmark_err "unknown handle \"$1\""
+		return 1
+	}
+	k=$doc:$2
+	[[ -n ${_testmark_start[$k]+set} ]] || {
+		_testmark_err "$1: no hunk named \"$2\""
+		return 1
+	}
+}
+
+_testmark_names() {
+	(($# == 1)) || {
+		_testmark_err "usage: testmark_names HANDLE"
+		return 1
+	}
+	[[ -n ${_testmark_doc[$1]-} ]] || {
+		_testmark_err "unknown handle \"$1\""
+		return 1
+	}
+	printf '%s' "${_testmark_names[$1]}"
+}
+
+_testmark_body() {
+	(($# == 2)) || {
+		_testmark_err "usage: testmark_body HANDLE NAME"
+		return 1
+	}
+	local k i start end body=
+	_testmark_hunk "$1" "$2" || return 1
+	start=${_testmark_start[$k]} end=${_testmark_end[$k]}
+	for ((i = start; i < end; i++)); do body+=${_testmark_lines[i]}; done
+	printf '%s' "$body"
+}
+
+_testmark_info() {
+	(($# == 2)) || {
+		_testmark_err "usage: testmark_info HANDLE NAME"
+		return 1
+	}
+	local k
+	_testmark_hunk "$1" "$2" || return 1
+	printf '%s\n' "${_testmark_info[$k]}"
+}
diff --git a/tests/lib.sh b/tests/lib.sh
new file mode 100644
index 0000000..a679d3c
--- /dev/null
+++ b/tests/lib.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+## Shared helpers for the testmark-bash tests.  Sourced, not executed.
+## Sources the library, makes a scratch dir and cd's into it, and provides PASS/FAIL reporting.
+## The helpers stay exact under the hostile settings test-hostile-env.sh turns on:
+## comparisons use `[ = ]`, which `nocasematch` doesn't touch.
+# shellcheck disable=SC2034 # RESULT is read by the test scripts
+
+set -euo pipefail
+
+TESTDATA=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/testdata
+# shellcheck source=lib/testmark.bash
+source "$TESTDATA/../../lib/testmark.bash"
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+cd "$work"
+
+RESULT=0
+
+_pass() {
+	printf 'PASS  %s\n' "$1"
+}
+
+_fail() {
+	printf 'FAIL  %s\n' "$1"
+	RESULT=1
+}
+
+## - @param $1 description
+## - @param $2 got
+## - @param $3 want
+## - @stdout one PASS or FAIL line
+## - @state RESULT -- set to 1 on failure
+expect_eq() {
+	if [ "$2" = "$3" ]; then
+		_pass "$1"
+	else
+		_fail "$1"$'\n'"--- got:"$'\n'"$2"$'\n'"--- want:"$'\n'"$3"
+	fi
+}
+
+## Quotes bytes the way the conformance harnesses do: printable ASCII as is, escapes for the rest.
+##
+## - @param $1 bytes
+## - @stdout the quoted string, without a newline
+esc() {
+	local LC_ALL=C s=$1 out='' c i
+	for ((i = 0; i < ${#s}; i++)); do
+		c=${s:i:1}
+		case $c in
+		'\') out+='\\' ;;
+		'"') out+='\"' ;;
+		$'\n') out+='\n' ;;
+		$'\r') out+='\r' ;;
+		$'\t') out+='\t' ;;
+		[\ -~]) out+=$c ;;
+		*)
+			printf -v c '\\x%02x' "'$c"
+			out+=$c
+			;;
+		esac
+	done
+	printf '"%s"' "$out"
+}
+
+## Prints every hunk of a parsed document: `hunk`, `info`, and `body` lines, values quoted by `esc`.
+##
+## - @param $1 handle
+## - @stdout the dump
+dump() {
+	local names name body
+	mapfile -t names < <(testmark_names "$1")
+	for name in "${names[@]}"; do
+		body=$(testmark_body "$1" "$name" && printf x) || return
+		printf 'hunk %s\ninfo %s\nbody %s\n' "$(esc "$name")" "$(esc "$(testmark_info "$1" "$name")")" "$(esc "${body%x}")"
+	done
+}
+
+## Parses FILE as handle "doc", and prints its dump, or "error: " and the error message.
+## A successful parse that still wrote to stderr gets a "stderr: " line too.
+##
+## - @param $1 file
+## - @stdout the dump or the error
+dump_parse() {
+	if testmark_parse doc "$1" 2>"$work/err"; then
+		dump doc
+		[[ ! -s $work/err ]] || printf 'stderr: %s\n' "$(<"$work/err")"
+	else
+		printf 'error: %s\n' "$(<"$work/err")"
+	fi
+}
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100755
index 0000000..0e219a4
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+## Run every tests/test-*.sh script.  Each prints PASS/FAIL lines and exits
+## nonzero on failure; this driver reports per-script status and fails if any did.
+set -uo pipefail
+cd "$(dirname "$0")" || exit
+
+fail=0
+for t in test-*.sh; do
+	echo "=== $t ==="
+	if "./$t"; then
+		echo "ok    $t"
+	else
+		echo "FAIL  $t (exit $?)"
+		fail=1
+	fi
+done
+
+echo
+if [[ $fail -eq 0 ]]; then
+	echo "all test scripts passed"
+else
+	echo "TEST FAILURES"
+fi
+exit "$fail"
diff --git a/tests/test-api.sh b/tests/test-api.sh
new file mode 100755
index 0000000..eccd71a
--- /dev/null
+++ b/tests/test-api.sh
@@ -0,0 +1,89 @@
+#!/bin/bash
+## The read API: handles, errors, and exact output.
+set -euo pipefail
+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
+}
+
+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
+: >empty.md
+
+# --- output is exact, and travels through files ---
+testmark_parse doc a.md
+testmark_names doc >out
+expect_eq "names: one per line, in document order" "$(od -c out)" "$(printf 'x\nold\n' | od -c)"
+testmark_body doc x >out
+expect_eq "body: exact bytes" "$(od -c out)" "$(printf 'one\n' | od -c)"
+testmark_info doc x >out
+expect_eq "info: then a newline" "$(od -c out)" "$(printf 'text\n' | od -c)"
+testmark_parse doc none.md
+testmark_names doc >out
+expect_eq "names: nothing, for no hunks" "$(wc -c <out)" "0"
+
+# --- several handles at once, and re-parsing ---
+testmark_parse a a.md
+testmark_parse b b.md
+testmark_parse e empty.md
+expect_eq "handle a" "$(testmark_body a x)" "one"
+expect_eq "handle b" "$(testmark_body b x)" "two"
+expect_fail "an empty doc doesn't see another doc's hunks" 'testmark: e: no hunk named "x"' testmark_body e x
+testmark_parse a b.md
+expect_eq "re-parse replaces the doc" "$(testmark_body a x)" "two"
+expect_fail "re-parse drops the old doc's names" 'testmark: a: no hunk named "old"' testmark_body a old
+expect_eq "re-parse leaves other handles alone" "$(testmark_names b)" "x"
+expect_fail "a failed re-parse fails" 'testmark: missing.md: not a readable file' testmark_parse b missing.md
+expect_fail "and leaves its handle unusable" 'testmark: unknown handle "b"' testmark_names b
+
+# --- sourcing: twice keeps state; inside a function still makes globals ---
+# shellcheck source=lib/testmark.bash
+source "$TESTDATA/../../lib/testmark.bash"
+expect_eq "sourcing again keeps open docs" "$(testmark_body a x)" "two"
+testmark_parse c none.md
+expect_fail "and later parses don't collide with them" 'testmark: c: no hunk named "x"' testmark_body c x
+expect_eq "sourcing inside a function" "$(
+	"$BASH" -c 'f() { source "$1"; }; f "$1"; testmark_parse d1 a.md; testmark_parse d2 b.md; testmark_body d1 x' _ "$TESTDATA/../../lib/testmark.bash"
+)" "one"
+
+# --- errors ---
+expect_fail "unknown handle" 'testmark: unknown handle "nope"' testmark_body nope x
+expect_fail "unknown hunk" 'testmark: a: no hunk named "nope"' testmark_info a nope
+expect_fail "invalid handle" 'testmark: invalid handle "a-b": must match [A-Za-z0-9_]+' testmark_parse a-b a.md
+expect_fail "empty handle" 'testmark: invalid handle "": must match [A-Za-z0-9_]+' testmark_parse '' a.md
+expect_fail "directory" 'testmark: .: not a readable file' testmark_parse a .
+expect_fail "parse arity" 'testmark: usage: testmark_parse HANDLE FILE' testmark_parse a
+expect_fail "names arity" 'testmark: usage: testmark_names HANDLE' testmark_names
+expect_fail "body arity" 'testmark: usage: testmark_body HANDLE NAME' testmark_body a x y
+expect_fail "info arity" 'testmark: usage: testmark_info HANDLE NAME' testmark_info a
+
+# --- hostile hunk names: plain data, never evaluated ---
+names=('$(>pwned1)' '`>pwned2`' 'a]b' 'x[0]' '@' '*' '$HOME' "'q'" '"q"' 'a\b')
+for name in "${names[@]}"; do
+	printf '[testmark]:# (%s)\n```\nbody of %s\n```\n\n' "$name" "$name"
+done >hostile.md
+testmark_parse h hostile.md
+mapfile -t got < <(testmark_names h)
+expect_eq "hostile names list" "$(printf '%s\n' "${got[@]}")" "$(printf '%s\n' "${names[@]}")"
+for name in "${names[@]}"; do
+	expect_eq "hostile name $name" "$(testmark_body h "$name")" "body of $name"
+done
+expect_fail "a hostile unknown name" 'testmark: h: no hunk named "$(>pwned3)"' testmark_body h '$(>pwned3)'
+expect_eq "nothing was executed" "$(echo pwned*)" "pwned*"
+
+exit "$RESULT"