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"