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`.

# 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]}"
}

lib/testmark/document.bash · 5.1 KiB · 421966087a