## 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 (a re-parse leaves the old ones behind, unused); per-hunk entries are keyed "HANDLE:NAME". # 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 -gA _testmark_base _testmark_pid _testmark_names # by handle: index of its first line (empty while unusable), $BASHPID at parse (state changes refuse other pids), names each ending in "\n" _testmark_base+=() _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_base[$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 } # 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[@]} # 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 _testmark_base[$h]=$base } ## Drops handle $1's per-hunk entries, by rebuilding each per-hunk array without them, ## since unsetting a single assoc element has no spelling that's safe for every key and setting. _testmark_forget() { 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_base[$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_base[$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]}" }