From 7f12f0eedd783a37384d4a8c8bd88c6f7b5450a4 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 6 Sep 2026 09:35:16 +0200 Subject: [PATCH] fix(coverage): isolate nested command spans Preserve outer literal and continued-command coverage across substitutions without crediting child commands. Track case phases and arithmetic contexts so delimiters cannot expose parent spans. Keep Bash and awk report propagation aligned. Refs #1338 --- CHANGELOG.md | 2 +- docs/coverage.md | 11 +- src/coverage/lines.sh | 311 ++++++++++++++++-- src/coverage/rules_awk.sh | 252 ++++++++++++-- .../bashunit_coverage_multiline_test.sh | 110 ++++++- .../coverage/classifier_differential_test.sh | 32 ++ tests/unit/coverage/spans_test.sh | 259 +++++++++++++++ 7 files changed, 908 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c15a1ea..ca31f18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Fixed -- Coverage counts every line of a multi-line statement as covered once the statement has run, not only the line Bash reported it on. An array literal written one element per line used to cost one uncovered line per element — and on Bash 3.2, where the assignment is reported on its closing `)`, the hit was discarded outright and the whole array read as uncovered. Multi-line strings and heredoc bodies had the same gap; a multi-line `$( )` is left alone, since its interior lines are commands that are tracked in their own right (#1338) +- Coverage propagates execution counts across multiline array literals, quoted strings, heredocs and backslash continuations, including when Bash 3.x records an array assignment on its closing `)`. Commands inside command and process substitutions remain individually tracked, even within quotes or arrays (#1338) ## [0.50.1](https://github.com/TypedDevs/bashunit/compare/0.50.0...0.50.1) - 2026-08-22 diff --git a/docs/coverage.md b/docs/coverage.md index a7994c0f..6a806908 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -447,7 +447,8 @@ commands=( # covered Every line still counts toward the denominator, so a multi-line statement that never runs reports as several uncovered lines, exactly as it did before. -A multi-line command substitution is deliberately **not** treated this way: +Commands inside a multi-line command or process substitution are tracked +individually, including substitutions inside a quoted string or array: ```bash result=$( @@ -456,10 +457,10 @@ result=$( ) ``` -`compute_a` and `compute_b` are commands in their own right and are tracked -individually, so crediting them from the line that opened the substitution -would report lines that never ran. (Before Bash 4 the tracer does not reach a -subshell at all — see [Subshell Behavior](#subshell-behavior).) +Crediting `compute_a` and `compute_b` from the line that opened the substitution +would report lines that never ran. Multiline literals inside the substitution +still receive coverage from their own execution. (Before Bash 4 the tracer does +not reach a subshell at all; see [Subshell Behavior](#subshell-behavior).) ## Branch Coverage diff --git a/src/coverage/lines.sh b/src/coverage/lines.sh index 74d67cff..35fe1d84 100644 --- a/src/coverage/lines.sh +++ b/src/coverage/lines.sh @@ -233,14 +233,16 @@ function bashunit::coverage::_ends_with_continuation() { # The state is a stack of open contexts, innermost last, one character each: # # S a single-quoted string D a double-quoted string -# A an array literal `name=(` C a command substitution `$(` -# P any other parenthesis +# A an array literal `name=(` C a command/process substitution +# R an arithmetic parenthesis P any other parenthesis +# H a case command header K a case expecting a pattern +# B a case arm body # # plus a pending heredoc delimiter. A line is *open* -- the statement continues # onto the next line -- when the stack holds an S, D or A, when a heredoc is # pending, or when it ends with a backslash continuation. # -# C and P deliberately do NOT open a span. The lines inside a multi-line `$( )` +# C, R and P deliberately do NOT open a span. The lines inside a multi-line `$( )` # are real commands that get their own DEBUG hits, so crediting them from the # line that opened the substitution would report lines that never ran. They are # still tracked, because their parentheses have to balance for the ones that do. @@ -248,6 +250,8 @@ _BASHUNIT_COVERAGE_SCAN_STACK="" _BASHUNIT_COVERAGE_SCAN_HEREDOC="" _BASHUNIT_COVERAGE_SCAN_HEREDOC_TAB=0 _BASHUNIT_COVERAGE_SCAN_CONTINUES=0 +_BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS="" +_BASHUNIT_COVERAGE_SCAN_COMMAND_START=1 ## # Clears the scanner state. Call once before the first line of a file. @@ -257,6 +261,8 @@ function bashunit::coverage::scan_reset() { _BASHUNIT_COVERAGE_SCAN_HEREDOC="" _BASHUNIT_COVERAGE_SCAN_HEREDOC_TAB=0 _BASHUNIT_COVERAGE_SCAN_CONTINUES=0 + _BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS="" + _BASHUNIT_COVERAGE_SCAN_COMMAND_START=1 } # Records the delimiter of a heredoc from the text that follows `<<`. The @@ -282,6 +288,28 @@ function bashunit::coverage::_scan_heredoc_start() { _BASHUNIT_COVERAGE_SCAN_HEREDOC="$word" } +# Keeps a backslash-continuation span at the command-substitution depth where +# it started. Deeper child commands suspend it; returning to its depth closes +# it unless that boundary line continues again. +function bashunit::coverage::_scan_update_continuation_span() { # $1 = stack, $2 = command-start flag + local stack="$1" command_start="$2" + local commands depth kept="" item + commands="${stack//[^C]/}" + depth=${#commands} + for item in $_BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS; do + if [ "$item" -lt "$depth" ]; then + kept="${kept:+$kept }$item" + fi + done + if [ "$_BASHUNIT_COVERAGE_SCAN_CONTINUES" -eq 1 ]; then + kept="${kept:+$kept }$depth" + _BASHUNIT_COVERAGE_SCAN_COMMAND_START=$command_start + else + _BASHUNIT_COVERAGE_SCAN_COMMAND_START=1 + fi + _BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS="$kept" +} + ## # Advances the scanner over one source line. # @@ -305,47 +333,105 @@ function bashunit::coverage::scan_line() { if [ "$body" = "$_BASHUNIT_COVERAGE_SCAN_HEREDOC" ]; then _BASHUNIT_COVERAGE_SCAN_HEREDOC="" fi + bashunit::coverage::_scan_update_continuation_span \ + "$_BASHUNIT_COVERAGE_SCAN_STACK" 1 return 0 fi + local stack="$_BASHUNIT_COVERAGE_SCAN_STACK" + local lead="${line#"${line%%[![:space:]]*}"}" + local keyword="${lead%%[[:space:];&|)]*}" + local command_start=$_BASHUNIT_COVERAGE_SCAN_COMMAND_START + # With nothing open, a line holding no quote, backslash, `(` or `<` cannot - # change the state: a `)` pops an empty stack and a `#` only ends a walk that - # would have done nothing. One glob test skips the walk for most lines. - if [ -z "$_BASHUNIT_COVERAGE_SCAN_STACK" ]; then - case "$line" in - *[\'\"\\\(\<]*) : ;; - *) return 0 ;; + # change the state unless it starts with a reserved word that introduces a + # command. One glob test skips the walk for most lines, while a continued + # command must still consume its word. + if [ -z "$stack" ] && [ -z "$_BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS" ] && + [ "$command_start" -eq 1 ]; then + case "$keyword" in + case | if | while | until | then | do | else | elif | '{' | '!') : ;; + *) + case "$line" in + *[\'\"\\\(\<\;\&\|]*) : ;; + *) + _BASHUNIT_COVERAGE_SCAN_STACK="$stack" + _BASHUNIT_COVERAGE_SCAN_COMMAND_START=1 + return 0 + ;; + esac + ;; esac fi - if bashunit::coverage::_ends_with_continuation "$line"; then - _BASHUNIT_COVERAGE_SCAN_CONTINUES=1 - fi - # A local copy, written back once: the global's name is longer than most of # the statements that touch it. - local stack="$_BASHUNIT_COVERAGE_SCAN_STACK" - local rest="$line" prev="" head tail char top + local rest="$line" prev="" head tail char top words token while [ -n "$rest" ]; do top="${stack#"${stack%?}"}" case "$top" in 'S') head="${rest%%[\']*}" ;; 'D') head="${rest%%[\"\\$]*}" ;; - *) head="${rest%%[\'\"\\#()<]*}" ;; + *) head="${rest%%[\'\"\\#()<;&|]*}" ;; esac - [ "$head" = "$rest" ] && break # `prev` is what sits immediately left of the character we stopped on: it is # the last character of the skipped text, or -- when nothing was skipped -- # the character the previous iteration stopped on. [ -n "$head" ] && prev="${head#"${head%?}"}" + + # Reserved words only have meaning at a shell command boundary. Consume + # just enough ordinary text to find case/in/esac in source order; quoted + # words and comments never reach this branch. Keeping case phases on the + # syntax stack prevents an arm delimiter from closing a C below it. + if [ "$command_start" -eq 1 ] && [ "$top" != 'S' ] && + [ "$top" != 'D' ] && [ "$top" != 'A' ] && [ "$top" != 'R' ]; then + words="$head" + while [ "$command_start" -eq 1 ] && [ -n "$words" ]; do + words="${words#"${words%%[![:space:]]*}"}" + [ -z "$words" ] && break + token="${words%%[[:space:]]*}" + words="${words#"$token"}" + top="${stack#"${stack%?}"}" + case "$top:$token" in + 'H:in') + stack="${stack%?}K" + command_start=1 + ;; + 'H:'*) + # Subject words continue until the unquoted `in` keyword. + command_start=1 + ;; + 'K:esac' | 'B:esac') + stack="${stack%?}" + command_start=0 + ;; + 'K:'*) + # At this point `case` is a pattern word, not a nested command. + command_start=0 + ;; + *':case') + stack="${stack}H" + command_start=1 + ;; + *':if' | *':while' | *':until' | *':then' | *':do' | *':else' | *':elif' | *':{' | *':!') + command_start=1 + ;; + *) command_start=0 ;; + esac + done + fi + + [ "$head" = "$rest" ] && break tail="${rest#"$head"}" char="${tail%"${tail#?}"}" rest="${tail#?}" + top="${stack#"${stack%?}"}" # Nothing but the closing quote is reported inside `'..'`. if [ "$top" = 'S' ]; then stack="${stack%?}" + [ "${stack#"${stack%?}"}" = 'H' ] && command_start=1 prev="$char" continue fi @@ -354,6 +440,8 @@ function bashunit::coverage::scan_line() { # `'..'` it is literal, and the branch above has already taken that case. case "$char" in [\\]) + [ -z "$rest" ] && _BASHUNIT_COVERAGE_SCAN_CONTINUES=1 + [ -n "$rest" ] && command_start=0 prev="${rest%"${rest#?}"}" rest="${rest#?}" continue @@ -362,13 +450,24 @@ function bashunit::coverage::scan_line() { if [ "$top" = 'D' ]; then case "$char" in - '"') stack="${stack%?}" ;; + '"') + stack="${stack%?}" + [ "${stack#"${stack%?}"}" = 'H' ] && command_start=1 + ;; '$') # `"$(cmd 'a"b')"`: a command substitution reopens an unquoted context, # so the quotes inside it are not the outer string's. case "$rest" in + '(('*) + stack="${stack}RR" + command_start=0 + rest="${rest#??}" + prev='(' + continue + ;; '('*) stack="${stack}C" + command_start=1 rest="${rest#?}" prev='(' continue @@ -381,8 +480,14 @@ function bashunit::coverage::scan_line() { fi case "$char" in - "'") stack="${stack}S" ;; - '"') stack="${stack}D" ;; + "'") + stack="${stack}S" + case "$top" in A | H) : ;; *) command_start=0 ;; esac + ;; + '"') + stack="${stack}D" + case "$top" in A | H) : ;; *) command_start=0 ;; esac + ;; '#') # `#` only opens a comment at the start of a word, so `${x#y}` and # `${#arr[@]}` are not comments. The rest of the line is not shell text. @@ -391,19 +496,65 @@ function bashunit::coverage::scan_line() { esac ;; '(') + # An optional `(` before a case pattern is not a grouping context. + if [ "$top" = 'K' ] && [ "$command_start" -eq 1 ]; then + command_start=0 + prev="$char" + continue + fi + case "$rest" in + '('*) + stack="${stack}RR" + rest="${rest#?}" + prev='(' + continue + ;; + esac # An array literal is the one parenthesis whose contents are words of a # single statement, and it is the one that opens right after a `=`. - case "$prev" in - '$') stack="${stack}C" ;; - '=') stack="${stack}A" ;; + case "$prev:$top" in + '$:'*) stack="${stack}C"; command_start=1 ;; + *:R) stack="${stack}R" ;; + '<:'* | '>:'*) stack="${stack}C"; command_start=1 ;; + '=:'*) stack="${stack}A" ;; *) stack="${stack}P" ;; esac ;; ')') - # A case arm's `)` closes nothing, and popping an empty stack is a no-op. - stack="${stack%?}" + # A case arm delimiter starts its command body. Popping an empty stack is + # a no-op. + if [ "$top" = 'K' ]; then + stack="${stack%?}B" + command_start=1 + else + stack="${stack%?}" + if [ "${stack#"${stack%?}"}" = 'H' ]; then + command_start=1 + else + command_start=0 + fi + fi + ;; + ';') + # `;;`, `;&` and `;;&` finish an arm and make the next word a pattern. + if [ "$top" = 'B' ]; then + case "$rest" in + ';'* | '&'*) stack="${stack%?}K" ;; + esac + fi + command_start=1 + ;; + '&') command_start=1 ;; + '|') + # Pattern alternatives remain pattern words, even when named `esac`. + if [ "$top" = 'K' ]; then command_start=0; else command_start=1; fi ;; '<') + # In arithmetic, `<<` is a bit shift, not a heredoc redirection. + if [ "$top" = 'R' ]; then + prev="$char" + continue + fi case "$rest" in '<<'*) # A here-string has no body. @@ -423,6 +574,7 @@ function bashunit::coverage::scan_line() { prev="$char" done + bashunit::coverage::_scan_update_continuation_span "$stack" "$command_start" _BASHUNIT_COVERAGE_SCAN_STACK="$stack" return 0 @@ -435,12 +587,46 @@ function bashunit::coverage::scan_line() { function bashunit::coverage::scan_is_open() { [ "$_BASHUNIT_COVERAGE_SCAN_CONTINUES" -eq 1 ] && return 0 [ -n "$_BASHUNIT_COVERAGE_SCAN_HEREDOC" ] && return 0 - case "$_BASHUNIT_COVERAGE_SCAN_STACK" in + # A substitution starts a new command context even inside a quote or array. + # Only literals opened INSIDE the innermost substitution may propagate hits. + case "${_BASHUNIT_COVERAGE_SCAN_STACK##*C}" in *[SDA]*) return 0 ;; esac return 1 } +# Describes the literal spans held by a scanner stack. A command substitution +# starts a new command depth; S, D and A contexts belong to the depth at which +# they opened. A parent literal can therefore stay alive while child commands +# at a deeper depth remain outside its line membership. +_BASHUNIT_COVERAGE_SCAN_COMMAND_DEPTH=0 +_BASHUNIT_COVERAGE_SCAN_SPAN_DEPTHS="" + +function bashunit::coverage::_scan_span_state() { # $1 = stack, $2 = continuation depths + local stack="$1" continuations="$2" char depth=0 spans="" item + while [ -n "$stack" ]; do + char="${stack%"${stack#?}"}" + stack="${stack#?}" + case "$char" in + C) depth=$((depth + 1)) ;; + S | D | A) + case " $spans " in + *" $depth "*) : ;; + *) spans="${spans:+$spans }$depth" ;; + esac + ;; + esac + done + for item in $continuations; do + case " $spans " in + *" $item "*) : ;; + *) spans="${spans:+$spans }$item" ;; + esac + done + _BASHUNIT_COVERAGE_SCAN_COMMAND_DEPTH=$depth + _BASHUNIT_COVERAGE_SCAN_SPAN_DEPTHS="$spans" +} + # Get all line hits for a file in one pass (performance optimization) # Output format: one "lineno:count" per line @@ -628,21 +814,76 @@ function bashunit::coverage::get_all_line_hits() { # span -- which is what Bash 3.2 does with an array literal (#1338). For a # backslash chain, where only the first line can carry a hit, it produces # exactly what the forward carry did (#722). - local idx start=1 max=0 fill h + local idx start=1 max=0 fill h is_open + local before_stack before_continuations before_depth before_spans after_depth after_spans + local depth seen was_active is_active member current + local -a span_lines=() + local -a span_max=() bashunit::coverage::scan_reset for ((idx = 1; idx <= total; idx++)); do h=${counts[idx]:-0} [ "$h" -gt "$max" ] && max=$h - bashunit::coverage::scan_line "${src[idx - 1]:-}" - bashunit::coverage::scan_is_open && continue - if [ "$max" -gt 0 ] && [ "$start" -lt "$idx" ]; then - for ((fill = start; fill <= idx; fill++)); do - counts[fill]=$max - done + before_stack="$_BASHUNIT_COVERAGE_SCAN_STACK" + before_continuations=$_BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS + bashunit::coverage::_scan_span_state "$before_stack" "$before_continuations" + before_depth=$_BASHUNIT_COVERAGE_SCAN_COMMAND_DEPTH + before_spans=$_BASHUNIT_COVERAGE_SCAN_SPAN_DEPTHS + + bashunit::coverage::scan_line "${src[idx - 1]:-}" + bashunit::coverage::_scan_span_state \ + "$_BASHUNIT_COVERAGE_SCAN_STACK" "$_BASHUNIT_COVERAGE_SCAN_CONTINUATION_DEPTHS" + after_depth=$_BASHUNIT_COVERAGE_SCAN_COMMAND_DEPTH + after_spans=$_BASHUNIT_COVERAGE_SCAN_SPAN_DEPTHS + + is_open=0 + bashunit::coverage::scan_is_open && is_open=1 + if [ "$is_open" -eq 0 ]; then + if [ "$max" -gt 0 ] && [ "$start" -lt "$idx" ]; then + for ((fill = start; fill <= idx; fill++)); do + counts[fill]=$max + done + fi + start=$((idx + 1)) + max=0 fi - start=$((idx + 1)) - max=0 + + # A literal around a substitution is one statement with discontiguous line + # membership. Its opening and closing boundary lines belong to the parent; + # lines wholly inside the child command do not. Keep the member list until + # the literal at that command depth closes, then apply its highest hit. + seen="" + for depth in $before_spans $after_spans; do + case " $seen " in *" $depth "*) continue ;; esac + seen="${seen:+$seen }$depth" + + was_active=0 + is_active=0 + case " $before_spans " in *" $depth "*) was_active=1 ;; esac + case " $after_spans " in *" $depth "*) is_active=1 ;; esac + + member=0 + if { [ "$was_active" -eq 1 ] && [ "$before_depth" -eq "$depth" ]; } || + { [ "$is_active" -eq 1 ] && [ "$after_depth" -eq "$depth" ]; } || + [ "$was_active" -ne "$is_active" ]; then + member=1 + fi + if [ "$member" -eq 1 ]; then + span_lines[depth]="${span_lines[depth]:-} $idx" + [ "$h" -gt "${span_max[depth]:-0}" ] && span_max[depth]=$h + fi + + if [ "$was_active" -eq 1 ] && [ "$is_active" -eq 0 ]; then + if [ "${span_max[depth]:-0}" -gt 0 ]; then + for fill in ${span_lines[depth]:-}; do + current=${counts[fill]:-0} + [ "${span_max[depth]}" -gt "$current" ] && counts[fill]=${span_max[depth]} + done + fi + span_lines[depth]="" + span_max[depth]=0 + fi + done done local ln diff --git a/src/coverage/rules_awk.sh b/src/coverage/rules_awk.sh index 3fd73c76..f1844647 100644 --- a/src/coverage/rules_awk.sh +++ b/src/coverage/rules_awk.sh @@ -112,7 +112,9 @@ function bu_ends_with_continuation(line, lead, i, n) { # reference and carries the full explanation of the state machine (#1338). # # _bu_st[1.._bu_sp] is the context stack, innermost last: S single-quoted, -# D double-quoted, A array literal, C command substitution, P other paren. +# D double-quoted, A array literal, C command/process substitution, +# R arithmetic paren, P other paren, H case command header, +# K case expecting a pattern, B case arm body. # The quote character itself has to be built with sprintf: this program lives # in a shell single-quoted string and so cannot contain one. function bu_scan_reset() { @@ -120,8 +122,11 @@ function bu_scan_reset() { _bu_hd = "" _bu_hdtab = 0 _bu_cont = 0 + _bu_command_start = 1 _bu_sq = sprintf("%c", 39) + _bu_special = "[" _bu_sq "\"\\\\#()<;&|]" split("", _bu_st) + split("", _bu_cont_depth) } # Records the delimiter of a heredoc from the text following `<<`. The quoting @@ -138,71 +143,211 @@ function bu_scan_heredoc(rest, word) { _bu_hd = word } -function bu_scan_line(line, i, n, c, prev, top, body) { +function bu_scan_pop_case() { + if (_bu_sp > 0 && (_bu_st[_bu_sp] == "K" || _bu_st[_bu_sp] == "B")) { + delete _bu_st[_bu_sp--] + } +} + +# Consumes ordinary unquoted words only while the shell expects a command. +# Separators are handled by bu_scan_line, so compact and nested case commands +# reach this function in source order without treating quoted text as syntax. +function bu_scan_words(text, token, top) { + while (_bu_command_start && text != "") { + sub(/^[ \t]+/, "", text) + if (text == "") { return } + token = text + sub(/[ \t].*$/, "", token) + text = substr(text, length(token) + 1) + top = (_bu_sp > 0) ? _bu_st[_bu_sp] : "" + if (top == "H" && token == "in") { + _bu_st[_bu_sp] = "K"; _bu_command_start = 1 + } else if (top == "H") { + # Subject words continue until the unquoted `in` keyword. + _bu_command_start = 1 + } else if ((top == "K" || top == "B") && token == "esac") { + bu_scan_pop_case(); _bu_command_start = 0 + } else if (top == "K") { + # `case` here is a pattern word, not a nested command. + _bu_command_start = 0 + } else if (token == "case") { + _bu_st[++_bu_sp] = "H"; _bu_command_start = 1 + } else if (token == "if" || token == "while" || token == "until" || + token == "then" || token == "do" || token == "else" || + token == "elif" || token == "{" || token == "!") { + _bu_command_start = 1 + } else { _bu_command_start = 0 } + } +} + +function bu_scan_update_continuation( k, depth) { + depth = 0 + for (k = 1; k <= _bu_sp; k++) { if (_bu_st[k] == "C") { depth++ } } + for (k in _bu_cont_depth) { + if ((k + 0) >= depth) { delete _bu_cont_depth[k] } + } + if (_bu_cont) { _bu_cont_depth[depth] = 1 } + else { _bu_command_start = 1 } +} + +function bu_scan_has_continuation( depth) { + for (depth in _bu_cont_depth) { return 1 } + return 0 +} + +function bu_scan_line(line, i, n, c, prev, top, body, rest, offset, lead, keyword, head) { _bu_cont = 0 if (_bu_hd != "") { body = line if (_bu_hdtab) { sub(/^\t+/, "", body) } if (body == _bu_hd) { _bu_hd = "" } + bu_scan_update_continuation() return } # Same early-out as the reference: with nothing open, a line holding none of - # these characters cannot change the state. index() is a C-speed pass where - # the walk below is an interpreted one. - if (_bu_sp == 0 && index(line, _bu_sq) == 0 && index(line, "\"") == 0 && - index(line, "\\") == 0 && index(line, "(") == 0 && index(line, "<") == 0) { + # these characters cannot change state unless it starts with a reserved word + # that introduces a command. + lead = line + sub(/^[ \t]+/, "", lead) + keyword = lead + sub(/[ \t;&|)].*$/, "", keyword) + if (_bu_sp == 0 && !bu_scan_has_continuation() && _bu_command_start && + keyword != "case" && keyword != "if" && keyword != "while" && + keyword != "until" && keyword != "then" && keyword != "do" && + keyword != "else" && keyword != "elif" && keyword != "{" && + keyword != "!" && index(line, _bu_sq) == 0 && index(line, "\"") == 0 && + index(line, "\\") == 0 && index(line, "(") == 0 && index(line, "<") == 0 && + index(line, ";") == 0 && index(line, "&") == 0 && index(line, "|") == 0) { return } - if (bu_ends_with_continuation(line)) { _bu_cont = 1 } - n = length(line) prev = "" for (i = 1; i <= n; i++) { + top = (_bu_sp > 0) ? _bu_st[_bu_sp] : "" + # Skip ordinary text in one native scan, as the Bash reference does. + rest = substr(line, i) + if (top == "S") { offset = index(rest, _bu_sq) } + else if (top == "D") { offset = match(rest, /["\\$]/) } + else { offset = match(rest, _bu_special) } + head = offset ? substr(rest, 1, offset - 1) : rest + if (_bu_command_start && top != "S" && top != "D" && top != "A" && + top != "R") { + bu_scan_words(head) + } + if (!offset) { break } + if (offset > 1) { prev = substr(rest, offset - 1, 1) } + i += offset - 1 c = substr(line, i, 1) top = (_bu_sp > 0) ? _bu_st[_bu_sp] : "" # Nothing but the closing quote is reported inside a single-quoted string. if (top == "S") { - if (c == _bu_sq) { _bu_sp-- } + if (c == _bu_sq) { + _bu_sp-- + if (_bu_sp > 0 && _bu_st[_bu_sp] == "H") { _bu_command_start = 1 } + } prev = c continue } # A backslash escapes the next character in both remaining contexts; inside # a single-quoted string it is literal, and the branch above took that case. - if (c == "\\") { i++; prev = substr(line, i, 1); continue } + if (c == "\\") { + if (i == n) { _bu_cont = 1 } + else { _bu_command_start = 0 } + i++; prev = substr(line, i, 1); continue + } if (top == "D") { - if (c == "\"") { _bu_sp-- } + if (c == "\"") { + _bu_sp-- + if (_bu_sp > 0 && _bu_st[_bu_sp] == "H") { _bu_command_start = 1 } + } else if (c == "$" && substr(line, i + 1, 1) == "(") { - _bu_sp++; _bu_st[_bu_sp] = "C"; i++; prev = "("; continue + if (substr(line, i + 2, 1) == "(") { + _bu_st[++_bu_sp] = "R"; _bu_st[++_bu_sp] = "R" + _bu_command_start = 0 + i += 2; prev = "("; continue + } + _bu_sp++; _bu_st[_bu_sp] = "C"; _bu_command_start = 1 + i++; prev = "("; continue } prev = c continue } - if (c == _bu_sq) { _bu_sp++; _bu_st[_bu_sp] = "S"; prev = c; continue } - if (c == "\"") { _bu_sp++; _bu_st[_bu_sp] = "D"; prev = c; continue } + if (c == _bu_sq) { + _bu_sp++; _bu_st[_bu_sp] = "S" + if (top != "A" && top != "H") { _bu_command_start = 0 } + prev = c; continue + } + if (c == "\"") { + _bu_sp++; _bu_st[_bu_sp] = "D" + if (top != "A" && top != "H") { _bu_command_start = 0 } + prev = c; continue + } if (c == "#") { if (prev == "" || prev == " " || prev == "\t" || - prev == ";" || prev == "&" || prev == "|") { return } + prev == ";" || prev == "&" || prev == "|") { + bu_scan_update_continuation() + return + } prev = c continue } if (c == "(") { + if (top == "K" && _bu_command_start) { + _bu_command_start = 0 + prev = c + continue + } + if (substr(line, i + 1, 1) == "(") { + _bu_st[++_bu_sp] = "R"; _bu_st[++_bu_sp] = "R" + i++; prev = "("; continue + } _bu_sp++ - _bu_st[_bu_sp] = (prev == "$") ? "C" : ((prev == "=") ? "A" : "P") + if (prev == "$") { _bu_st[_bu_sp] = "C"; _bu_command_start = 1 } + else if (top == "R") { _bu_st[_bu_sp] = "R" } + else if (prev == "<" || prev == ">") { + _bu_st[_bu_sp] = "C"; _bu_command_start = 1 + } + else { _bu_st[_bu_sp] = (prev == "=") ? "A" : "P" } prev = c continue } if (c == ")") { - if (_bu_sp > 0) { _bu_sp-- } + if (top == "K") { _bu_st[_bu_sp] = "B"; _bu_command_start = 1 } + else { + if (_bu_sp > 0) { _bu_sp-- } + _bu_command_start = (_bu_sp > 0 && _bu_st[_bu_sp] == "H") + } + prev = c + continue + } + if (c == ";") { + if (top == "B" && (substr(line, i + 1, 1) == ";" || + substr(line, i + 1, 1) == "&")) { + _bu_st[_bu_sp] = "K" + } + _bu_command_start = 1 + prev = c + continue + } + if (c == "&") { + _bu_command_start = 1 prev = c continue } + if (c == "|") { + # Pattern alternatives remain pattern words, even when named `esac`. + _bu_command_start = (top == "K") ? 0 : 1 + prev = c + continue + } + if (c == "<" && top == "R") { prev = c; continue } if (c == "<" && substr(line, i + 1, 1) == "<") { if (substr(line, i + 2, 1) == "<") { i += 2; prev = "<"; continue } bu_scan_heredoc(substr(line, i + 2)) @@ -212,12 +357,14 @@ function bu_scan_line(line, i, n, c, prev, top, body) { } prev = c } + bu_scan_update_continuation() } function bu_scan_open( k) { if (_bu_cont) { return 1 } if (_bu_hd != "") { return 1 } - for (k = 1; k <= _bu_sp; k++) { + for (k = _bu_sp; k > 0; k--) { + if (_bu_st[k] == "C") { return 0 } if (_bu_st[k] == "S" || _bu_st[k] == "D" || _bu_st[k] == "A") { return 1 } } return 0 @@ -232,24 +379,81 @@ function bu_scan_stack( k, s) { return s } +# Describes the literal spans held by the scanner stack before or after a line. +# Command substitutions increase the command depth. Literal contexts remain +# attached to the depth at which they opened while child commands are scanned. +function bu_scan_span_state(after, k, depth, c) { + depth = 0 + if (after) { split("", _bu_after_span) } + else { split("", _bu_before_span) } + for (k = 1; k <= _bu_sp; k++) { + c = _bu_st[k] + if (c == "C") { depth++ } + else if (c == "S" || c == "D" || c == "A") { + if (after) { _bu_after_span[depth] = 1 } + else { _bu_before_span[depth] = 1 } + } + } + for (k in _bu_cont_depth) { + if (after) { _bu_after_span[k] = 1 } + else { _bu_before_span[k] = 1 } + } + if (after) { _bu_after_depth = depth } + else { _bu_before_depth = depth } +} + # Gives every line of a multi-line statement the highest count recorded # anywhere in it. The DEBUG trap reports the statement on one line of the span # and which one depends on the Bash version, so the propagation runs in both # directions (#722, #1338). Mirrors the loop in get_all_line_hits. -function bu_propagate(sl, hits, total, ln, start, max, fill, h) { +function bu_propagate(sl, hits, total, ln, start, max, fill, h, open, d, was, now, member, n, parts, current) { bu_scan_reset() + split("", span_lines) + split("", span_max) start = 1 max = 0 for (ln = 1; ln <= total; ln++) { h = (ln in hits) ? hits[ln] + 0 : 0 if (h > max) { max = h } + + bu_scan_span_state(0) bu_scan_line(sl[ln]) - if (bu_scan_open()) { continue } - if (max > 0 && start < ln) { - for (fill = start; fill <= ln; fill++) { hits[fill] = max } + bu_scan_span_state(1) + + open = bu_scan_open() + if (!open) { + if (max > 0 && start < ln) { + for (fill = start; fill <= ln; fill++) { hits[fill] = max } + } + start = ln + 1 + max = 0 + } + + split("", seen) + for (d in _bu_before_span) { seen[d] = 1 } + for (d in _bu_after_span) { seen[d] = 1 } + for (d in seen) { + was = (d in _bu_before_span) + now = (d in _bu_after_span) + member = (was && _bu_before_depth == d) || + (now && _bu_after_depth == d) || (was != now) + if (member) { + span_lines[d] = span_lines[d] " " ln + if (h > span_max[d]) { span_max[d] = h } + } + if (was && !now) { + if (span_max[d] > 0) { + n = split(span_lines[d], parts, " ") + for (fill = 1; fill <= n; fill++) { + if (parts[fill] == "") { continue } + current = (parts[fill] in hits) ? hits[parts[fill]] + 0 : 0 + if (span_max[d] > current) { hits[parts[fill]] = span_max[d] } + } + } + delete span_lines[d] + delete span_max[d] + } } - start = ln + 1 - max = 0 } } ' diff --git a/tests/acceptance/bashunit_coverage_multiline_test.sh b/tests/acceptance/bashunit_coverage_multiline_test.sh index 8399055a..29ad4070 100644 --- a/tests/acceptance/bashunit_coverage_multiline_test.sh +++ b/tests/acceptance/bashunit_coverage_multiline_test.sh @@ -58,20 +58,29 @@ SRC source "$(dirname "${BASH_SOURCE[0]}")/src/multiline.sh" +function set_up() { + OUTPUT_FILE="$(bashunit::temp_file)" +} + +# Run in the test shell: Bash 3.0 does not trace command substitutions. function test_multi_line_array() { - assert_contains "start" "$(multi_line_array)" + multi_line_array >"$OUTPUT_FILE" + assert_file_contains "$OUTPUT_FILE" "start" } function test_backslash_continuation() { - assert_contains "start" "$(backslash_continuation)" + backslash_continuation >"$OUTPUT_FILE" + assert_file_contains "$OUTPUT_FILE" "start" } function test_multi_line_string() { - assert_contains "spend" "$(multi_line_string)" + multi_line_string >"$OUTPUT_FILE" + assert_file_contains "$OUTPUT_FILE" "spend" } function test_here_document() { - assert_contains "one" "$(here_document)" + here_document >"$OUTPUT_FILE" + assert_file_contains "$OUTPUT_FILE" "one" } TEST } @@ -80,6 +89,7 @@ function _run_coverage() { # $1 = dir local dir="$1" shift (cd "$dir" && BASHUNIT_COVERAGE_SHOW_UNCOVERED=true \ + BASHUNIT_COVERAGE_SHOW_LINE_HITS=true \ "$ROOT_DIR/bashunit" --no-parallel --coverage --coverage-paths src \ --no-coverage-report "$@" t_test.sh 2>&1) || true } @@ -134,3 +144,95 @@ SRC assert_contains "16/ 20 lines" "$output" assert_matches "src/multiline.sh:[0-9]+-[0-9]+" "$output" } + +function test_substitutions_keep_skipped_commands_uncovered_in_every_report() { + local dir + dir="$(bashunit::temp_dir)" + _project "$dir" + cat >>"$dir/src/multiline.sh" <<'SRC' + +function substitutions() { + local result + result="$( + if false; then + echo skipped_quoted + fi + )" + result=( + "outer_first" + "$( + if false; then + echo skipped_array + fi + )" + "outer_last" + <( + if false; then + echo skipped_process + fi + ) + ) + printf '%s\n' \ + "$( + if false; then + echo skipped_continuation + fi + )" \ + continued_last >/dev/null +} +SRC + cat >>"$dir/t_test.sh" <<'TEST' + +function test_substitutions() { + substitutions + assert_successful_code +} +TEST + + # Bash 3.2's old command-substitution parser mistakes a case arm `)` for the + # end of `$()`. Newer Bash versions parse it and exercise the case regression + # through all three reports; the scanner unit test covers the old runtime. + if [ "${BASH_VERSINFO[0]}" -ge 4 ]; then + cat >>"$dir/src/multiline.sh" <<'SRC' + +function case_substitution() { + local result + result="$( + case x in + x) + if false; then + echo skipped_case_child + fi + ;; + esac + )" +} +SRC + cat >>"$dir/t_test.sh" <<'TEST' + +function test_case_substitution() { + case_substitution + assert_successful_code +} +TEST + fi + + local output + output="$(_run_coverage "$dir" --coverage-report lcov.info --coverage-report-html html | strip_ansi)" + + assert_contains "All tests passed" "$output" + assert_contains "Uncovered Lines" "$output" + local ln html lcov + html="$(cat "$dir"/html/files/*.html)" + lcov="$(cat "$dir/lcov.info")" + while IFS= read -r ln; do + assert_file_contains "$dir/lcov.info" "DA:$ln,0" + assert_contains "id=\"line-$ln\" class=\"uncovered line-anchor\"" "$html" + done < <(awk '/echo skipped_/ { print NR }' "$dir/src/multiline.sh") + + while IFS= read -r ln; do + assert_matches "src/multiline.sh: .* ${ln}:[1-9]" "$output" + assert_matches "DA:$ln,[1-9]" "$lcov" + assert_contains "id=\"line-$ln\" class=\"covered line-anchor\"" "$html" + done < <(awk '/outer_(first|last)|continued_last/ { print NR }' "$dir/src/multiline.sh") +} diff --git a/tests/unit/coverage/classifier_differential_test.sh b/tests/unit/coverage/classifier_differential_test.sh index b82d4d07..7904df0d 100644 --- a/tests/unit/coverage/classifier_differential_test.sh +++ b/tests/unit/coverage/classifier_differential_test.sh @@ -176,6 +176,38 @@ function test_the_rule_sets_agree_on_the_quirk_cases() { printf '\t%s\n' 'body' printf '\t%s\n' 'EOF' printf '%s\n' 'read -r v <<<"here"' + printf '%s\n' 'x="$(' ' values=(' ' "one"' ' "two"' ' )' ' if false; then' ' : skipped' ' fi' ')"' + printf '%s\n' 'x=(' ' <(' ' : command' ' )' ')' + printf '%s\n' 'result="$(' ' case "$value" in' ' x)' ' if false; then' \ + ' echo skipped_case_child' ' fi' ' ;;' ' esac' ')"' + printf '%s\n' 'result="$(' ' case x in' ' x) case y in' ' y) : ;;' \ + ' esac ;;' ' z)' ' if false; then' ' echo skipped_nested_case' \ + ' fi' ' ;;' ' esac' ')"' + printf '%s\n' 'result="$(' ' case case in' ' case)' ' : arm' \ + ' ;;' ' esac' ')"' + printf '%s\n' 'result="$(' ' case x in' ' (x) case y in' ' y) : ;;' \ + ' esac ;;' ' esac' ')"' + printf '%s\n' 'result="$(' ' case z in (x) case y in' ' y) : ;;' \ + ' esac ;;' ' esac' ')"' + printf '%s\n' 'result="$(' ' case z in' ' foo|esac)' ' : arm' \ + ' ;;' ' esac' ')"' + printf '%s\n' 'result="$(' ' if case x in' ' x) false ;;' ' esac' \ + ' then :; fi' ' while case x in' ' x) false ;;' ' esac' \ + ' do :; done' ' until case x in' ' x) false ;;' ' esac' \ + ' do :; done' ')"' + # shellcheck disable=SC1003 # literal trailing backslash in child command + printf '%s\n' 'result="$(' ' printf ran \' ')"' 'result="$(' \ + ' if false; then' ' echo skipped_after_child_continuation' ' fi' ')"' + # shellcheck disable=SC1003 # literal trailing backslash before heredoc + printf '%s\n' 'return 0 <"$fixture" assert_same "$(bash_classification "$fixture")" "$(awk_classification "$fixture")" diff --git a/tests/unit/coverage/spans_test.sh b/tests/unit/coverage/spans_test.sh index 33e6661f..449efedd 100644 --- a/tests/unit/coverage/spans_test.sh +++ b/tests/unit/coverage/spans_test.sh @@ -36,6 +36,25 @@ function hits_for() { # $1 = source file, $@ = line numbers to record bashunit::coverage::get_all_line_hits "$src" } +# Runs the mirrored awk propagation against an explicit raw hit list. +function awk_hits_for() { # $1 = source file, $@ = line numbers to record + local src="$1" + shift + env LC_ALL=C "$AWK" -v raw_hits="$*" "$(bashunit::coverage::awk_rules)"' + BEGIN { + n = split(raw_hits, raw, " ") + for (i = 1; i <= n; i++) { if (raw[i] != "") { hits[raw[i]]++ } } + } + { total++; source[total] = $0 } + END { + bu_propagate(source, hits, total) + for (ln = 1; ln <= total; ln++) { + if ((ln in hits) && hits[ln] > 0) { print ln ":" hits[ln] } + } + } + ' "$src" +} + # --- the scanner ------------------------------------------------------------- # Runs the scanner over the given lines and reports the open flag of each, so a @@ -54,6 +73,23 @@ function open_flags() { # $@ = source lines printf '%s' "$out" } +function stack_for_file() { # $1 = source file + local line + bashunit::coverage::scan_reset + while IFS= read -r line || [ -n "$line" ]; do + bashunit::coverage::scan_line "$line" + done <"$1" + printf '%s' "$_BASHUNIT_COVERAGE_SCAN_STACK" +} + +function awk_stack_for_file() { # $1 = source file + env LC_ALL=C "$AWK" "$(bashunit::coverage::awk_rules)"' + BEGIN { bu_scan_reset() } + { bu_scan_line($0) } + END { printf "%s", bu_scan_stack() } + ' "$1" +} + function test_an_array_literal_stays_open_until_its_closing_paren() { assert_same "1110" "$(open_flags 'local commands=(' ' "start"' ' "stop"' ')')" } @@ -66,6 +102,22 @@ function test_a_command_substitution_is_not_a_span() { assert_same "00" "$(open_flags 'x=$(' ')')" } +function test_a_quote_around_a_command_substitution_does_not_open_a_span() { + assert_same "00000" "$(open_flags 'x="$(' ' if false; then' ' echo skipped' ' fi' ')"')" +} + +function test_an_array_around_a_substitution_does_not_cover_its_commands() { + assert_same "1000010" "$(open_flags 'x=(' '$(' 'if false; then' 'echo skipped' 'fi' ')' ')')" +} + +function test_a_literal_inside_a_quoted_substitution_still_opens_a_span() { + assert_same "011100" "$(open_flags 'x="$(' ' value=(' ' "one"' ' "two"' ' )' ')"')" +} + +function test_a_process_substitution_inside_an_array_is_not_a_span() { + assert_same "10010" "$(open_flags 'x=(' ' <(' ' compute' ' )' ')')" +} + function test_a_subshell_is_not_a_span() { assert_same "00" "$(open_flags '(' ')')" } @@ -120,6 +172,15 @@ function test_a_backslash_continuation_is_open() { assert_same "10" "$(open_flags 'printf %s \' ' value')" } +function test_a_backslash_in_a_trailing_comment_does_not_continue_the_statement() { + assert_same "00" "$(open_flags 'echo ran # comment \' 'echo skipped')" +} + +function test_arithmetic_shifts_do_not_start_a_heredoc() { + assert_same "00000" "$(open_flags 'x=$((1 << 2))' 'x="$((1 << 2))"' '((x = (1 << 2)))' 'echo done' '')" + assert_same "00" "$(open_flags 'x=$((1 <<(2 << 1)))' 'echo done')" +} + function test_an_arithmetic_expansion_balances() { assert_same "0" "$(open_flags 'count=$((count + 1))')" } @@ -128,6 +189,18 @@ function test_a_case_arm_does_not_unbalance_the_stack() { assert_same "000" "$(open_flags 'case "$x" in' ' --flag) run ;;' 'esac')" } +function test_a_case_arm_inside_a_quoted_substitution_does_not_close_it() { + assert_same "000000000" "$(open_flags 'result="$(' ' case "$value" in' ' x)' \ + ' if false; then' ' echo skipped' ' fi' ' ;;' ' esac' ')"')" +} + +function test_a_nested_case_after_an_arm_delimiter_keeps_both_cases_open() { + assert_same "000000000000" "$(open_flags 'result="$(' ' case x in' \ + ' x) case y in' ' y) : ;;' ' esac ;;' ' z)' \ + ' if false; then' ' echo skipped' ' fi' ' ;;' \ + ' esac' ')"')" +} + # --- span propagation -------------------------------------------------------- function test_an_array_hit_on_the_closing_paren_covers_the_whole_span() { @@ -169,6 +242,192 @@ function test_a_multi_line_command_substitution_is_left_alone() { assert_same "1:1" "$(hits_for "$src" 1)" } +function test_a_quoted_command_substitution_does_not_cover_skipped_commands() { + local src="$WORK/quoted_cmdsub.sh" + printf '%s\n' 'x="$(' ' if false; then' ' echo skipped' ' fi' ')"' >"$src" + + assert_same "$(printf '%s\n' '1:1' '5:1')" "$(hits_for "$src" 1)" +} + +function test_an_array_command_substitution_does_not_cover_skipped_commands() { + local src="$WORK/array_cmdsub.sh" + printf '%s\n' 'x=(' ' $(' ' if false; then' ' echo skipped' ' fi' ' )' ')' >"$src" + + assert_same "$(printf '%s\n' '1:1' '2:1' '6:1' '7:1')" "$(hits_for "$src" 1)" +} + +function test_an_outer_opening_hit_crosses_a_substitution_without_covering_its_child() { + local src="$WORK/outer_open.sh" + printf '%s\n' 'values=(' ' first' ' "$(' ' printf nested' ' )"' ' last' ')' >"$src" + + local expected + expected="$(printf '%s\n' '1:1' '2:1' '3:1' '5:1' '6:1' '7:1')" + assert_same "$expected" "$(hits_for "$src" 1)" + assert_same "$expected" "$(awk_hits_for "$src" 1)" +} + +function test_an_outer_closing_hit_crosses_a_substitution_without_covering_its_child() { + local src="$WORK/outer_close.sh" + printf '%s\n' 'values=(' ' first' ' "$(' ' printf nested' ' )"' ' last' ')' >"$src" + + local expected + expected="$(printf '%s\n' '1:1' '2:1' '3:1' '5:1' '6:1' '7:1')" + assert_same "$expected" "$(hits_for "$src" 7)" + assert_same "$expected" "$(awk_hits_for "$src" 7)" +} + +function test_a_child_hit_does_not_escape_to_its_outer_literal() { + local src="$WORK/outer_child.sh" + printf '%s\n' 'values=(' ' first' ' "$(' ' printf nested' ' )"' ' last' ')' >"$src" + + assert_same '4:1' "$(hits_for "$src" 4)" + assert_same '4:1' "$(awk_hits_for "$src" 4)" +} + +function test_a_case_arm_does_not_expose_the_outer_quote_to_a_skipped_command() { + local src="$WORK/case_arm.sh" + printf '%s\n' 'result="$(' ' case "$value" in' ' x)' ' if false; then' \ + ' echo skipped' ' fi' ' ;;' ' esac' ')"' >"$src" + + assert_same '4:1' "$(hits_for "$src" 4)" + assert_same '4:1' "$(awk_hits_for "$src" 4)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_a_nested_case_does_not_expose_the_outer_quote_to_a_skipped_command() { + local src="$WORK/nested_case.sh" + printf '%s\n' 'result="$(' ' case x in' ' x) case y in' ' y) : ;;' \ + ' esac ;;' ' z)' ' if false; then' ' echo skipped' \ + ' fi' ' ;;' ' esac' ')"' >"$src" + + assert_same '7:1' "$(hits_for "$src" 7)" + assert_same '7:1' "$(awk_hits_for "$src" 7)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_a_continued_command_crosses_a_substitution_without_covering_its_child() { + local src="$WORK/continued_substitution.sh" + printf '%s\n' 'printf "%s\n" \' ' "$(' ' printf nested' ' )" \' \ + ' last' >"$src" + + local expected + expected="$(printf '%s\n' '1:1' '2:1' '4:1' '5:1')" + assert_same "$expected" "$(hits_for "$src" 1)" + assert_same "$expected" "$(awk_hits_for "$src" 1)" +} + +function test_a_child_continuation_ends_when_its_substitution_closes() { + local src="$WORK/child_continuation.sh" + printf '%s\n' 'result="$(' ' printf ran \' ')"' 'result="$(' \ + ' if false; then' ' echo skipped' ' fi' ')"' >"$src" + + local expected + expected="$(printf '%s\n' '2:1' '3:1')" + assert_same "$expected" "$(hits_for "$src" 2)" + assert_same "$expected" "$(awk_hits_for "$src" 2)" +} + +function test_a_heredoc_clears_its_leading_continuation() { + local src="$WORK/heredoc_continuation.sh" + printf '%s\n' 'return 0 <"$src" + + local expected + expected="$(printf '%s\n' '1:1' '2:1' '3:1')" + assert_same "$expected" "$(hits_for "$src" 1)" + assert_same "$expected" "$(awk_hits_for "$src" 1)" +} + +function test_case_pattern_named_case_keeps_skipped_commands_isolated() { + local src="$WORK/case_pattern_named_case.sh" + printf '%s\n' 'result="$(' ' case case in' ' case)' \ + ' if false; then' ' echo skipped' ' fi' ' ;;' \ + ' esac' ')"' >"$src" + + assert_same '4:1' "$(hits_for "$src" 4)" + assert_same '4:1' "$(awk_hits_for "$src" 4)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_optional_case_pattern_keeps_nested_case_commands_isolated() { + local src="$WORK/optional_nested_case.sh" + printf '%s\n' 'result="$(' ' case x in' ' (x) case y in' \ + ' y) : ;;' ' esac ;;' ' z)' ' if false; then' \ + ' echo skipped' ' fi' ' ;;' ' esac' ')"' >"$src" + + assert_same '7:1' "$(hits_for "$src" 7)" + assert_same '7:1' "$(awk_hits_for "$src" 7)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_compact_optional_case_pattern_keeps_nested_commands_isolated() { + local src="$WORK/compact_optional_nested_case.sh" + printf '%s\n' 'result="$(' ' case z in (x) case y in' ' y) : ;;' \ + ' esac ;;' ' z)' ' if false; then' ' echo skipped' ' fi' \ + ' ;;' ' esac' ')"' >"$src" + + assert_same '6:1' "$(hits_for "$src" 6)" + assert_same '6:1' "$(awk_hits_for "$src" 6)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_case_pattern_alternative_named_esac_stays_in_the_pattern() { + local src="$WORK/case_pattern_esac_alternative.sh" + printf '%s\n' 'result="$(' ' case z in' ' foo|esac)' \ + ' if false; then' ' echo skipped' ' fi' ' ;;' \ + ' esac' ')"' >"$src" + + assert_same '4:1' "$(hits_for "$src" 4)" + assert_same '4:1' "$(awk_hits_for "$src" 4)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_conditional_case_commands_keep_later_commands_isolated() { + local keyword branch end src + for keyword in if while until; do + if [ "$keyword" = 'if' ]; then + branch='then' + end='fi' + else + branch='do' + end='done' + fi + src="$WORK/${keyword}_case.sh" + printf '%s\n' 'result="$(' " $keyword case x in" ' x) false ;;' \ + ' esac' " $branch" ' :' " $end" ' if false; then' \ + ' echo skipped' ' fi' ')"' >"$src" + + assert_same '3:1' "$(hits_for "$src" 3)" + assert_same '3:1' "$(awk_hits_for "$src" 3)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" + done +} + +function test_arithmetic_case_variable_does_not_hide_a_following_array_span() { + local src="$WORK/arithmetic_case_variable.sh" + printf '%s\n' 'value=$((' ' case' ' + 1' '))' 'items=(' ' one' ')' >"$src" + + local expected + expected="$(printf '%s\n' '5:1' '6:1' '7:1')" + assert_same "$expected" "$(hits_for "$src" 7)" + assert_same "$expected" "$(awk_hits_for "$src" 7)" + assert_empty "$(stack_for_file "$src")" + assert_empty "$(awk_stack_for_file "$src")" +} + +function test_an_arithmetic_shift_does_not_swallow_a_later_array_span() { + local src="$WORK/shift.sh" + printf '%s\n' 'x=$((1 << 2))' 'items=(' ' one' ' two' ')' >"$src" + + assert_same "$(printf '%s\n' '2:1' '3:1' '4:1' '5:1')" "$(hits_for "$src" 5)" +} + function test_a_span_that_never_ran_stays_uncovered() { local src="$WORK/cold.sh" printf '%s\n' 'local commands=(' ' "start"' ')' 'echo done' >"$src"