## 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`: # 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; state changes refuse other pids # So forgetting a handle is one `unset` of whole variables, and nothing else needs cleaning up. # Names are built only from handles checked against [A-Za-z0-9_]+, and kinds have no "_", so two handles' names never meet. # Parse works in locals and commits at its very end, so a failed parse leaves nothing behind. # Globals are reached through namerefs; assoc ones are declared first, since a nameref to an undeclared name makes an indexed array. # Hunk names are hostile input as keys: arithmetic, `[[ -v ]]`, `unset`, and `printf -v` on an element can 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_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 } unset -v "_testmark_lines_$h" "_testmark_start_$h" "_testmark_end_$h" "_testmark_info_$h" "_testmark_names_$h" "_testmark_pid_$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 } local -a lines mapfile lines <"$file" || { _testmark_err "$file: read failed" 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%')'} if [[ $line != '[testmark]:# ('*')' || ! $name =~ ^[[:space:]]*([^[:space:]]+) ]]; then _testmark_err "$file:$((i + 1)): malformed annotation; want \"[testmark]:# (name)\"" return 1 fi k=${BASH_REMATCH[1]} [[ -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 } 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() { [[ $1 =~ ^[A-Za-z0-9_]+$ && -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]}" }