## 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 at _testmark_next; forgetting a handle unsets its lines, leaving a hole. # Per-hunk entries are keyed "HANDLE:NAME", and forgetting a handle rebuilds those arrays without its keys. # 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. # Handles once checked against [A-Za-z0-9_]+, and line indexes (the library's own numbers), are safe in any form. 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_next=${_testmark_next-0} # next free index in _testmark_lines; with holes, the element count isn't it declare -gA _testmark_base _testmark_count # by handle: its lines' range in _testmark_lines, recorded as soon as they're read, so a failed parse's lines get freed too _testmark_base+=() _testmark_count+=() declare -gA _testmark_pid _testmark_names # by handle: $BASHPID at parse (empty while unusable; state changes refuse other pids), names each ending in "\n" _testmark_pid+=() _testmark_names+=() declare -gA _testmark_start _testmark_end _testmark_info # by "HANDLE: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_pid[$h]= _testmark_forget "$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 } # Everything from _testmark_next on is unset, so the growth in element count is the number of lines read. local base=$_testmark_next before=${#_testmark_lines[@]} end mapfile -O "$base" _testmark_lines <"$file" || { _testmark_err "$file: read failed" return 1 } end=$((base + ${#_testmark_lines[@]} - before)) _testmark_next=$end _testmark_base[$h]=$base _testmark_count[$h]=$((end - base)) # 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=$h:$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 } ## Drops handle $1's lines, and its per-hunk entries. ## The per-hunk arrays are rebuilt without them, since unsetting an element keyed by a hunk name has no spelling that's safe for every key and setting. _testmark_forget() { local i base=${_testmark_base[$1]-0} count=${_testmark_count[$1]-0} for ((i = base; i < base + count; i++)); do unset -v "_testmark_lines[$i]"; done local k local -A start=() end=() info=() for k in "${!_testmark_start[@]}"; do [[ $k == "$1:"* ]] || start[$k]=${_testmark_start[$k]}; done for k in "${!_testmark_end[@]}"; do [[ $k == "$1:"* ]] || end[$k]=${_testmark_end[$k]}; done for k in "${!_testmark_info[@]}"; do [[ $k == "$1:"* ]] || info[$k]=${_testmark_info[$k]}; done _testmark_start=() _testmark_end=() _testmark_info=() for k in "${!start[@]}"; do _testmark_start[$k]=${start[$k]}; done for k in "${!end[@]}"; do _testmark_end[$k]=${end[$k]}; done for k in "${!info[@]}"; do _testmark_info[$k]=${info[$k]}; done } ## Sets the caller's local `k` to the storage key of hunk $2 in document $1, or fails with a message. _testmark_hunk() { [[ -n ${_testmark_pid[$1]-} ]] || { _testmark_err "unknown handle \"$1\"" return 1 } k=$1:$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_pid[$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]}" }