@@ -13,10 +13,40 @@ _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-
1313# File to store which tests hit each line (for detailed coverage tooltips)
1414_BASHUNIT_COVERAGE_TEST_HITS_FILE=" ${_BASHUNIT_COVERAGE_TEST_HITS_FILE:- } "
1515
16- # Store the subshell level when coverage trap is enabled
17- # Used to skip recording in nested subshells (command substitution)
18- # Uses $BASH_SUBSHELL which is Bash 3.2 compatible (unlike $BASHPID)
19- _BASHUNIT_COVERAGE_SUBSHELL_LEVEL=" ${_BASHUNIT_COVERAGE_SUBSHELL_LEVEL:- } "
16+ # Auto-discover coverage paths from test file names
17+ # When no explicit coverage paths are set, find source files matching test file base names
18+ # Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh
19+ function bashunit::coverage::auto_discover_paths() {
20+ local project_root
21+ project_root=" $( pwd) "
22+ local -a discovered_paths=()
23+
24+ for test_file in " $@ " ; do
25+ # Extract base name: tests/unit/assert_test.sh -> assert_test.sh
26+ local file_basename
27+ file_basename=$( basename " $test_file " )
28+
29+ # Remove test suffixes to get source name: assert_test.sh -> assert
30+ local source_name=" ${file_basename% _test.sh} "
31+ [[ " $source_name " == " $file_basename " ]] && source_name=" ${file_basename% Test.sh} "
32+ [[ " $source_name " == " $file_basename " ]] && continue # Not a test file pattern
33+
34+ # Find matching source files recursively
35+ while IFS= read -r -d ' ' found_file; do
36+ # Skip test files and vendor directories
37+ [[ " $found_file " == * test* ]] && continue
38+ [[ " $found_file " == * Test* ]] && continue
39+ [[ " $found_file " == * vendor* ]] && continue
40+ [[ " $found_file " == * node_modules* ]] && continue
41+ discovered_paths+=(" $found_file " )
42+ done < <( find " $project_root " -name " ${source_name} *.sh" -type f -print0 2> /dev/null)
43+ done
44+
45+ # Return unique paths, comma-separated
46+ if [[ ${# discovered_paths[@]} -gt 0 ]]; then
47+ printf ' %s\n' " ${discovered_paths[@]} " | sort -u | tr ' \n' ' ,' | sed ' s/,$//'
48+ fi
49+ }
2050
2151function bashunit::coverage::init() {
2252 if ! bashunit::env::is_coverage_enabled; then
@@ -58,11 +88,6 @@ function bashunit::coverage::enable_trap() {
5888 return 0
5989 fi
6090
61- # Store the subshell level for nested subshell detection
62- # $BASH_SUBSHELL increments in each nested subshell (Bash 3.2 compatible)
63- _BASHUNIT_COVERAGE_SUBSHELL_LEVEL=" $BASH_SUBSHELL "
64- export _BASHUNIT_COVERAGE_SUBSHELL_LEVEL
65-
6691 # Enable trap inheritance into functions
6792 set -T
6893
@@ -99,11 +124,6 @@ function bashunit::coverage::record_line() {
99124 # Skip if coverage data file doesn't exist (trap inherited by child process)
100125 [[ -z " $_BASHUNIT_COVERAGE_DATA_FILE " ]] && return 0
101126
102- # Skip recording in nested subshells (command substitution like $(...))
103- # $BASH_SUBSHELL increments in each nested subshell
104- # This prevents interference with tests that capture output
105- [[ -n " $_BASHUNIT_COVERAGE_SUBSHELL_LEVEL " && " $BASH_SUBSHELL " -gt " $_BASHUNIT_COVERAGE_SUBSHELL_LEVEL " ]] && return 0
106-
107127 # Skip if not tracking this file (uses cache internally)
108128 bashunit::coverage::should_track " $file " || return 0
109129
@@ -299,6 +319,15 @@ function bashunit::coverage::is_executable_line() {
299319 # Skip lines with only braces
300320 [[ " $line " =~ ^[[:space:]]* [\{\} ][[:space:]]* $ ]] && return 1
301321
322+ # Skip control flow keywords (then, else, fi, do, done, esac, in, ;;, ;&, ;;&)
323+ [[ " $line " =~ ^[[:space:]]* (then| else| fi| do| done| esac| in| ;; | ;;& | ;& )[[:space:]]* (# .*)?$ ]] && return 1
324+
325+ # Skip case patterns like "--option)" or "*)"
326+ [[ " $line " =~ ^[[:space:]]* [^\) ]+\) [[:space:]]* $ ]] && return 1
327+
328+ # Skip standalone ) for arrays/subshells
329+ [[ " $line " =~ ^[[:space:]]* \) [[:space:]]* (# .*)?$ ]] && return 1
330+
302331 return 0
303332}
304333
@@ -775,7 +804,7 @@ function bashunit::coverage::report_html() {
775804 # Generate index.html
776805 bashunit::coverage::generate_index_html \
777806 " $output_dir /index.html" " $total_hit " " $total_executable " " $total_pct " \
778- " $tests_total " " $tests_passed " " $tests_failed " " ${file_data[@]} "
807+ " $tests_total " " $tests_passed " " $tests_failed " ${file_data[@]+ " ${file_data[@]} " }
779808
780809 echo " Coverage HTML report written to: $output_dir /index.html"
781810}
@@ -789,11 +818,13 @@ function bashunit::coverage::generate_index_html() {
789818 local tests_passed=" $6 "
790819 local tests_failed=" $7 "
791820 shift 7
792- local file_data=(" $@ " )
821+ local file_data=()
822+ [[ $# -gt 0 ]] && file_data=(" $@ " )
793823
794824 # Calculate uncovered lines and file count
795825 local total_uncovered=$(( total_executable - total_hit))
796- local file_count=${# file_data[@]}
826+ local file_count=0
827+ [[ ${# file_data[@]} -gt 0 ]] && file_count=${# file_data[@]}
797828
798829 # Calculate gauge stroke offset (440 is full circle circumference)
799830 local gauge_offset=$(( 440 - (440 * total_pct / 100 )) )
@@ -1082,7 +1113,7 @@ EOF
10821113 <tbody>
10831114EOF
10841115
1085- for data in " ${file_data[@]} " ; do
1116+ for data in ${file_data[@]+ " ${file_data[@]} " } ; do
10861117 IFS=' |' read -r display_file hit executable pct safe_filename <<< " $data"
10871118
10881119 local class=" low"
0 commit comments