testmark-bash

document.bash

## document.bash: parsing a testmark document, and reading its hunks.
## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`.
# shellcheck disable=SC2034,SC2178 # shellcheck does not follow namerefs: writes through them look unused, and reusing a name as one looks like an array turned string

# Storage: a document's state lives only in globals named after its handle, `_testmark_KIND_HANDLE`, one per kind in _testmark_kinds:
#   lines        indexed: the lines, each keeping its "\n" (a file's last line may lack one), CRLF made LF
#   start, end   assoc by hunk name: the body's line range [start, end) in lines
#   info         assoc by hunk name: the info string
#   names        the hunk names in document order, each ending in "\n"
#   pid          $BASHPID at parse, for refusing state changes from a subshell
# So forgetting a handle is one `unset` per kind, and nothing else needs cleaning up.
# Variable names are built only from handles that pass _testmark_handle, and kinds have no "_", so two handles' variables never meet.
# Hunk names come from documents and may contain shell syntax: on some bash versions, arithmetic, `[[ -v ]]`, `unset`, and `printf -v` on an element run `$(...)` from inside its key.
# So touch name-keyed elements only as `a[$k]=v`, `"${a[$k]}"`, `"${a[$k]-}"`, `${a[$k]+set}`, and `"${!a[@]}"`.
_testmark_kinds=(lines start end info names pid)

_testmark_err() {
	>&2 printf 'testmark: %s\n' "$1"
}

## Fails with a message unless $1 is a valid handle.
_testmark_handle() {
	[[ -n $1 && $1 != *[!A-Za-z0-9_]* ]] || {
		_testmark_err "invalid handle \"$1\": must match [A-Za-z0-9_]+"
		return 1
	}
}

_testmark_parse() {
	(($# == 2)) || {
		_testmark_err "usage: testmark_parse HANDLE FILE"
		return 1
	}
	local h=$1 file=$2 kind
	_testmark_handle "$h" || return 1
	for kind in "${_testmark_kinds[@]}"; do unset -v "_testmark_${kind}_$h"; done
	local -a lines
	# See "use blocks to open an fd and check properties on it without TOCTOU" in docs/hacking/bash-pitfalls.md.
	{ [[ ! -d /dev/stdin ]] && mapfile lines; } <"$file" || {
		_testmark_err "$file: not a readable file"
		return 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 -A start=() end=() info=()
	local i line name names='' open='' k='' at=''
	for ((i = 0; i < ${#lines[@]}; i++)); do
		line=${lines[i]}
		# 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:$((i + 1)): contains a NUL byte"
			return 1
		fi
		if [[ $line == *$'\r\n' ]]; then
			line=${line%$'\r\n'}
			lines[i]=$line$'\n'
		else
			line=${line%$'\n'}
		fi

		if [[ -n $open ]]; then
			if [[ $line == '```'* ]]; then
				[[ -z $k ]] || end[$k]=$i
				open='' k=''
			fi
		elif [[ -n $k ]]; then
			[[ $line == '```'* ]] || {
				_testmark_err "$file:$at: annotation not followed by a code block"
				return 1
			}
			line=${line#'```'}
			# Trim: strip the leading run of whitespace, then the trailing one.
			line=${line#"${line%%[![:space:]]*}"}
			info[$k]=${line%"${line##*[![:space:]]}"}
			start[$k]=$((i + 1))
			open=1 at=$((i + 1))
		elif [[ $line == '```'* ]]; then
			open=1 at=$((i + 1))
		elif [[ $line == '[testmark]:#'* ]]; then
			name=${line#'[testmark]:# ('}
			name=${name%')'}
			# The name is the first word: skip leading whitespace, then stop at the next.
			name=${name#"${name%%[![:space:]]*}"}
			name=${name%%[[:space:]]*}
			if [[ $line != '[testmark]:# ('*')' || -z $name ]]; then
				_testmark_err "$file:$((i + 1)): malformed annotation; want \"[testmark]:# (name)\""
				return 1
			fi
			k=$name
			[[ -z ${start[$k]+set} ]] || {
				_testmark_err "$file:$((i + 1)): duplicate hunk name \"$k\""
				return 1
			}
			names+=$k$'\n'
			at=$((i + 1))
		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
	}

	# Parsed without error; only now are globals written.
	# Assoc kinds are declared first, since a nameref to an undeclared name makes an indexed array.
	declare -gA "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h"
	local -n g_lines=_testmark_lines_$h g_start=_testmark_start_$h g_end=_testmark_end_$h g_info=_testmark_info_$h
	local -n g_names=_testmark_names_$h g_pid=_testmark_pid_$h
	g_lines=("${lines[@]}")
	for name in "${!start[@]}"; do g_start[$name]=${start[$name]} g_end[$name]=${end[$name]} g_info[$name]=${info[$name]}; done
	g_names=$names g_pid=$BASHPID
}

## Fails with a message unless $1 is the handle of a parsed document.
_testmark_doc() {
	_testmark_handle "$1" || return 1
	[[ -v _testmark_pid_$1 ]] || {
		_testmark_err "unknown handle \"$1\""
		return 1
	}
}

## Fails with a message unless $1 is the handle of a parsed document with a hunk named $2.
_testmark_hunk() {
	_testmark_doc "$1" || return 1
	local -n start=_testmark_start_$1
	[[ -n ${start[$2]+set} ]] || {
		_testmark_err "$1: no hunk named \"$2\""
		return 1
	}
}

_testmark_names() {
	(($# == 1)) || {
		_testmark_err "usage: testmark_names HANDLE"
		return 1
	}
	_testmark_doc "$1" || return 1
	local -n names=_testmark_names_$1
	printf '%s' "$names"
}

_testmark_body() {
	(($# == 2)) || {
		_testmark_err "usage: testmark_body HANDLE NAME"
		return 1
	}
	_testmark_hunk "$1" "$2" || return 1
	local -n lines=_testmark_lines_$1 start=_testmark_start_$1 end=_testmark_end_$1
	local i=${start[$2]} last=${end[$2]} body=
	for (( ; i < last; i++)); do body+=${lines[i]}; done
	printf '%s' "$body"
}

_testmark_info() {
	(($# == 2)) || {
		_testmark_err "usage: testmark_info HANDLE NAME"
		return 1
	}
	_testmark_hunk "$1" "$2" || return 1
	local -n info=_testmark_info_$1
	printf '%s\n' "${info[$2]}"
}

lib/testmark/document.bash · 5.8 KiB · 8d0fe9cfbc