From e0bc32e352b0b34f9f58e361e1fc72adcfd87eb7 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 21 Apr 2026 03:15:04 +0200 Subject: [PATCH 1/2] fix(coverage): ignore case comments and done redirections Coverage counted `*pat) # comment` and loop terminators with redirections or pipes (`done < file`, `done <<<"$var"`, `done | sort`) as executable lines. Update is_executable_line regex to skip trailing comments on case patterns and treat `done` followed by `<`, `>`, `|` or `&` as a non-executable loop terminator. Closes #634 --- CHANGELOG.md | 1 + src/coverage.sh | 7 +++-- tests/unit/coverage_core_test.sh | 43 ++++++++++++++++++++++++++ tests/unit/coverage_executable_test.sh | 43 ++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) 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..a43efacf 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 redirection or pipe (e.g. "done < file", "done <<<\"$var\"", "done | sort") + [ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*done[[: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..a2467a5d 100644 --- a/tests/unit/coverage_executable_test.sh +++ b/tests/unit/coverage_executable_test.sh @@ -219,3 +219,46 @@ 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" +} From 4c5645acf2c8bdf4489dc7e7e3c73a1c7e16f496 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 21 Apr 2026 03:22:31 +0200 Subject: [PATCH 2/2] test(coverage): cover fd redirects, background and append forms on done Widen the loop-terminator skip to match `done 2>&1`, `done &`, `done >>` and other non-space trailing forms. Regex now requires any non-space, non-comment char after `done ` so any valid trailing redirection, fd redirect, pipe or background marker keeps `done` non-executable. --- src/coverage.sh | 4 ++-- tests/unit/coverage_executable_test.sh | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index a43efacf..7cab433d 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -449,8 +449,8 @@ 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 loop terminator with redirection or pipe (e.g. "done < file", "done <<<\"$var\"", "done | sort") - [ "$(echo "$line" | "$GREP" -cE '^[[:space:]]*done[[: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 diff --git a/tests/unit/coverage_executable_test.sh b/tests/unit/coverage_executable_test.sh index a2467a5d..453ab930 100644 --- a/tests/unit/coverage_executable_test.sh +++ b/tests/unit/coverage_executable_test.sh @@ -262,3 +262,21 @@ function test_coverage_is_executable_line_returns_false_for_done_with_pipe() { 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" +}