diff --git a/CHANGELOG.md b/CHANGELOG.md index a58060c0..2ce8f4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/coverage.sh b/src/coverage.sh index 794ff346..7cab433d 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -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 diff --git a/tests/unit/coverage_core_test.sh b/tests/unit/coverage_core_test.sh index 3a58b155..2ded4a14 100644 --- a/tests/unit/coverage_core_test.sh +++ b/tests/unit/coverage_core_test.sh @@ -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) diff --git a/tests/unit/coverage_executable_test.sh b/tests/unit/coverage_executable_test.sh index 765090b1..453ab930 100644 --- a/tests/unit/coverage_executable_test.sh +++ b/tests/unit/coverage_executable_test.sh @@ -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" +}