Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=(` … `)`)
Expand All @@ -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=$(
Expand Down
35 changes: 23 additions & 12 deletions src/coverage/lines.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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' | *':{' | *':!')
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
;;
';')
Expand Down
19 changes: 13 additions & 6 deletions src/coverage/rules_awk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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.
Expand All @@ -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 == "!") {
Expand Down Expand Up @@ -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
Expand All @@ -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) == "(") {
Expand All @@ -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 == "#") {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/coverage/classifier_differential_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' ')"'
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/coverage/spans_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading