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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- LCOV and HTML coverage reports no longer produce empty output under `set -e` (#618)
- `clock::now` handles `EPOCHREALTIME` values that use a comma decimal separator
- Invalid `.env.example` coverage threshold entry; CI now copies `.env.example` to `.env` so config parse errors are caught
- Coverage no longer counts case patterns with trailing comments (e.g. `*thing) # note`) or loop terminators with redirections/pipes (e.g. `done < file`, `done <<<"$var"`, `done | sort`) as executable lines (#634)

## [0.34.1](https://github.com/TypedDevs/bashunit/compare/0.34.0...0.34.1) - 2026-03-20

Expand Down
7 changes: 5 additions & 2 deletions src/coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,11 @@ function bashunit::coverage::is_executable_line() {
# Skip control flow keywords (then, else, fi, do, done, esac, in, ;;, ;&, ;;&)
[ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$' || true)" -gt 0 ] && return 1

# Skip case patterns like "--option)" or "*)"
[ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*[^\)]+\)[[:space:]]*$' || true)" -gt 0 ] && return 1
# Skip loop terminator with trailing redirection/pipe/fd (e.g. "done < file", "done | sort", "done 2>&1", "done &")
[ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*done[[:space:]]+[^[:space:]#].*$' || true)" -gt 0 ] && return 1

# Skip case patterns like "--option)" or "*) # comment"
[ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*[^\)]+\)[[:space:]]*(#.*)?$' || true)" -gt 0 ] && return 1

# Skip standalone ) for arrays/subshells
[ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*\)[[:space:]]*(#.*)?$' || true)" -gt 0 ] && return 1
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/coverage_core_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,49 @@ EOF
rm -f "$temp_file"
}

function test_coverage_get_executable_lines_ignores_case_comments_and_done_redirects() {
# Regression for #634: case patterns with trailing comments and loop terminators
# with redirections or pipes must be ignored when counting executable lines.
local temp_file
temp_file=$(mktemp)

cat >"$temp_file" <<'EOF'
#!/usr/bin/env bash
function demo() {
case "$1" in
*thing) # Looks for thing at end of text
echo "thing"
;;
*) # fallback branch
echo "other"
;;
esac

while read -r line; do
echo "$line"
done < /path/to/file

while read -r item; do
echo "$item"
done <<<"$some_var"

while read -r x; do
echo "$x"
done | sort
}
EOF

# Executable lines: case "$1" in, echo "thing", echo "other",
# while read -r line, echo "$line", while read -r item, echo "$item",
# while read -r x, echo "$x" -> 9 total.
local count
count=$(bashunit::coverage::get_executable_lines "$temp_file")

assert_equals "9" "$count"

rm -f "$temp_file"
}

function test_coverage_get_executable_lines_does_not_exit_under_set_e() {
local temp_file
temp_file=$(mktemp)
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/coverage_executable_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,64 @@ function test_coverage_is_executable_line_returns_false_for_standalone_paren() {
result=$(bashunit::coverage::is_executable_line ' )' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_case_pattern_with_comment() {
local input=' *thing) # Looks for thing at end of text'
local result
result=$(bashunit::coverage::is_executable_line "$input" 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_wildcard_case_with_comment() {
local result
result=$(bashunit::coverage::is_executable_line ' *) # fallback' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_file_redirect() {
local result
result=$(bashunit::coverage::is_executable_line ' done < /path/to/file' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_herestring() {
local result
result=$(bashunit::coverage::is_executable_line ' done <<<"$var"' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_process_sub() {
local result
result=$(bashunit::coverage::is_executable_line ' done < <(some_cmd)' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_redirect_and_comment() {
local result
result=$(bashunit::coverage::is_executable_line ' done < "$file" # read input' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_pipe() {
local result
result=$(bashunit::coverage::is_executable_line ' done | sort' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_fd_redirect() {
local result
result=$(bashunit::coverage::is_executable_line ' done 2>&1' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_background() {
local result
result=$(bashunit::coverage::is_executable_line ' done &' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done_with_append_redirect() {
local result
result=$(bashunit::coverage::is_executable_line ' done >> /tmp/out.log' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}
Loading