diff --git a/CHANGELOG.md b/CHANGELOG.md index ca31f18c..dd4a09cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Fixed -- 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) +- 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 `)`. Parent statement hits no longer mark commands inside command or process substitutions as covered, including 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 6a806908..73633a41 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -429,8 +429,9 @@ These lines are not counted toward coverage: ### Statements That Span Several Lines Bash reports one executed statement to the tracer on a single line, even when -the statement is written over several. bashunit spreads that hit across every -line the statement occupies, so a statement that ran is covered on all of them: +the statement is written over several. bashunit propagates that hit across the +statement's physical span, so every executable line in a statement that ran is +covered: - backslash continuations (`printf '%s' \` … ) - array literals (`commands=(` … `)`) @@ -444,11 +445,12 @@ commands=( # covered ) # not executable ``` -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. +Executable lines remain in the denominator, so an unexecuted multiline +statement can contribute several uncovered lines. -Commands inside a multi-line command or process substitution are tracked -individually, including substitutions inside a quoted string or array: +Parent statement hits do not cover commands inside a multi-line command or +process substitution, even when the substitution appears inside a quoted string +or array: ```bash result=$( diff --git a/src/coverage/lines.sh b/src/coverage/lines.sh index 35fe1d84..275e0fbe 100644 --- a/src/coverage/lines.sh +++ b/src/coverage/lines.sh @@ -235,8 +235,8 @@ function bashunit::coverage::_ends_with_continuation() { # S a single-quoted string D a double-quoted string # 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 +# Q a case awaiting its subject H a case header after its subject +# 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 @@ -394,6 +394,11 @@ function bashunit::coverage::scan_line() { words="${words#"$token"}" top="${stack#"${stack%?}"}" case "$top:$token" in + 'Q:'*) + # A case subject may itself be the word `in`. + stack="${stack%?}H" + command_start=1 + ;; 'H:in') stack="${stack%?}K" command_start=1 @@ -411,7 +416,7 @@ function bashunit::coverage::scan_line() { command_start=0 ;; *':case') - stack="${stack}H" + stack="${stack}Q" command_start=1 ;; *':if' | *':while' | *':until' | *':then' | *':do' | *':else' | *':elif' | *':{' | *':!') @@ -431,7 +436,10 @@ function bashunit::coverage::scan_line() { # Nothing but the closing quote is reported inside `'..'`. if [ "$top" = 'S' ]; then stack="${stack%?}" - [ "${stack#"${stack%?}"}" = 'H' ] && command_start=1 + case "${stack#"${stack%?}"}" in + 'Q') stack="${stack%?}H"; command_start=1 ;; + 'H') command_start=1 ;; + esac prev="$char" continue fi @@ -452,7 +460,10 @@ function bashunit::coverage::scan_line() { case "$char" in '"') stack="${stack%?}" - [ "${stack#"${stack%?}"}" = 'H' ] && command_start=1 + case "${stack#"${stack%?}"}" in + 'Q') stack="${stack%?}H"; command_start=1 ;; + 'H') command_start=1 ;; + esac ;; '$') # `"$(cmd 'a"b')"`: a command substitution reopens an unquoted context, @@ -482,11 +493,11 @@ function bashunit::coverage::scan_line() { case "$char" in "'") stack="${stack}S" - case "$top" in A | H) : ;; *) command_start=0 ;; esac + case "$top" in A | Q | H) : ;; *) command_start=0 ;; esac ;; '"') stack="${stack}D" - case "$top" in A | H) : ;; *) command_start=0 ;; esac + case "$top" in A | Q | H) : ;; *) command_start=0 ;; esac ;; '#') # `#` only opens a comment at the start of a word, so `${x#y}` and @@ -528,11 +539,11 @@ function bashunit::coverage::scan_line() { command_start=1 else stack="${stack%?}" - if [ "${stack#"${stack%?}"}" = 'H' ]; then - command_start=1 - else - command_start=0 - fi + case "${stack#"${stack%?}"}" in + 'Q') stack="${stack%?}H"; command_start=1 ;; + 'H') command_start=1 ;; + *) command_start=0 ;; + esac fi ;; ';') diff --git a/src/coverage/rules_awk.sh b/src/coverage/rules_awk.sh index f1844647..b69622fd 100644 --- a/src/coverage/rules_awk.sh +++ b/src/coverage/rules_awk.sh @@ -113,8 +113,9 @@ function bu_ends_with_continuation(line, lead, i, n) { # # _bu_st[1.._bu_sp] is the context stack, innermost last: S single-quoted, # 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. +# R arithmetic paren, P other paren, Q case awaiting its subject, +# H case header after its subject, 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() { @@ -160,7 +161,10 @@ function bu_scan_words(text, token, top) { sub(/[ \t].*$/, "", token) text = substr(text, length(token) + 1) top = (_bu_sp > 0) ? _bu_st[_bu_sp] : "" - if (top == "H" && token == "in") { + if (top == "Q") { + # A case subject may itself be the word `in`. + _bu_st[_bu_sp] = "H"; _bu_command_start = 1 + } else 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. @@ -171,7 +175,7 @@ function bu_scan_words(text, token, top) { # `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 + _bu_st[++_bu_sp] = "Q"; _bu_command_start = 1 } else if (token == "if" || token == "while" || token == "until" || token == "then" || token == "do" || token == "else" || token == "elif" || token == "{" || token == "!") { @@ -247,6 +251,7 @@ function bu_scan_line(line, i, n, c, prev, top, body, rest, offset, lead, keyw if (top == "S") { if (c == _bu_sq) { _bu_sp-- + if (_bu_sp > 0 && _bu_st[_bu_sp] == "Q") { _bu_st[_bu_sp] = "H" } if (_bu_sp > 0 && _bu_st[_bu_sp] == "H") { _bu_command_start = 1 } } prev = c @@ -264,6 +269,7 @@ function bu_scan_line(line, i, n, c, prev, top, body, rest, offset, lead, keyw if (top == "D") { if (c == "\"") { _bu_sp-- + if (_bu_sp > 0 && _bu_st[_bu_sp] == "Q") { _bu_st[_bu_sp] = "H" } if (_bu_sp > 0 && _bu_st[_bu_sp] == "H") { _bu_command_start = 1 } } else if (c == "$" && substr(line, i + 1, 1) == "(") { @@ -281,12 +287,12 @@ function bu_scan_line(line, i, n, c, prev, top, body, rest, offset, lead, keyw if (c == _bu_sq) { _bu_sp++; _bu_st[_bu_sp] = "S" - if (top != "A" && top != "H") { _bu_command_start = 0 } + if (top != "A" && top != "Q" && 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 } + if (top != "A" && top != "Q" && top != "H") { _bu_command_start = 0 } prev = c; continue } if (c == "#") { @@ -322,6 +328,7 @@ function bu_scan_line(line, i, n, c, prev, top, body, rest, offset, lead, keyw if (top == "K") { _bu_st[_bu_sp] = "B"; _bu_command_start = 1 } else { if (_bu_sp > 0) { _bu_sp-- } + if (_bu_sp > 0 && _bu_st[_bu_sp] == "Q") { _bu_st[_bu_sp] = "H" } _bu_command_start = (_bu_sp > 0 && _bu_st[_bu_sp] == "H") } prev = c diff --git a/tests/unit/coverage/classifier_differential_test.sh b/tests/unit/coverage/classifier_differential_test.sh index 7904df0d..c97483c1 100644 --- a/tests/unit/coverage/classifier_differential_test.sh +++ b/tests/unit/coverage/classifier_differential_test.sh @@ -187,7 +187,7 @@ function test_the_rule_sets_agree_on_the_quirk_cases() { ' ;;' ' 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) : ;;' \ + printf '%s\n' 'result="$(' ' case in in (x) case y in' ' y) : ;;' \ ' esac ;;' ' esac' ')"' printf '%s\n' 'result="$(' ' case z in' ' foo|esac)' ' : arm' \ ' ;;' ' esac' ')"' diff --git a/tests/unit/coverage/spans_test.sh b/tests/unit/coverage/spans_test.sh index 449efedd..69750cd4 100644 --- a/tests/unit/coverage/spans_test.sh +++ b/tests/unit/coverage/spans_test.sh @@ -363,9 +363,9 @@ function test_optional_case_pattern_keeps_nested_case_commands_isolated() { assert_empty "$(awk_stack_for_file "$src")" } -function test_compact_optional_case_pattern_keeps_nested_commands_isolated() { +function test_case_subject_named_in_keeps_compact_nested_commands_isolated() { local src="$WORK/compact_optional_nested_case.sh" - printf '%s\n' 'result="$(' ' case z in (x) case y in' ' y) : ;;' \ + printf '%s\n' 'result="$(' ' case in in (x) case y in' ' y) : ;;' \ ' esac ;;' ' z)' ' if false; then' ' echo skipped' ' fi' \ ' ;;' ' esac' ')"' >"$src"