Skip to content

Commit 6afbded

Browse files
authored
perf(coverage): single-pass file scan + native regex in extract_functions (#644)
1 parent 894e4e0 commit 6afbded

3 files changed

Lines changed: 142 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
### Changed
99
- Speed up coverage report generation by collapsing the per-line non-executable pattern checks in `bashunit::coverage::is_executable_line` into a single combined `grep` invocation (#636)
10+
- Speed up coverage report generation further by combining executable + hit counting into a single source-file pass (`bashunit::coverage::compute_file_coverage`) shared across text/lcov/html reporters, removing per-line `get_line_hits` scans of the coverage data file (#636)
11+
- Replace `echo | sed` / `echo | grep` subshells in `bashunit::coverage::extract_functions` with bash native regex matching and parameter expansion (#636)
1012

1113
## [0.35.0](https://github.com/TypedDevs/bashunit/compare/0.34.1...0.35.0) - 2026-04-26
1214

src/coverage.sh

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,10 @@ function bashunit::coverage::calculate_percentage() {
173173
# Get file coverage stats as "executable:hit:pct:class"
174174
function bashunit::coverage::get_file_stats() {
175175
local file="$1"
176-
local executable hit pct class
177-
executable=$(bashunit::coverage::get_executable_lines "$file")
178-
hit=$(bashunit::coverage::get_hit_lines "$file")
176+
local stats executable hit pct class
177+
stats=$(bashunit::coverage::compute_file_coverage "$file")
178+
executable="${stats%%:*}"
179+
hit="${stats##*:}"
179180
pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable")
180181
class=$(bashunit::coverage::get_coverage_class "$pct")
181182
echo "${executable}:${hit}:${pct}:${class}"
@@ -517,6 +518,30 @@ function bashunit::coverage::get_line_hits() {
517518
echo "$count"
518519
}
519520

521+
# Compute executable + hit counts for a file in a single source-file pass.
522+
# Reuses get_all_line_hits to avoid scanning the coverage data per line.
523+
# Output format: "executable:hit"
524+
function bashunit::coverage::compute_file_coverage() {
525+
local file="$1"
526+
527+
local -a hits_by_line=()
528+
local hit_lineno hit_count
529+
while IFS=: read -r hit_lineno hit_count; do
530+
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
531+
done < <(bashunit::coverage::get_all_line_hits "$file")
532+
533+
local executable=0 hit=0 lineno=0 line line_hits
534+
while IFS= read -r line || [ -n "$line" ]; do
535+
lineno=$((lineno + 1))
536+
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
537+
executable=$((executable + 1))
538+
line_hits=${hits_by_line[lineno]:-0}
539+
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
540+
done <"$file"
541+
542+
echo "${executable}:${hit}"
543+
}
544+
520545
# Get all line hits for a file in one pass (performance optimization)
521546
# Output format: one "lineno:count" per line
522547
function bashunit::coverage::get_all_line_hits() {
@@ -571,12 +596,16 @@ function bashunit::coverage::extract_functions() {
571596
if [ "$in_function" -eq 0 ]; then
572597
local fn_name=""
573598

574-
# Match: function name() or function name {
599+
# Match: name() with optional `function` keyword (parens form)
575600
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
576-
fn_name=$(echo "$line" | sed -nE "s/$_re/\2/p")
577-
if [ -z "$fn_name" ]; then
601+
if [[ "$line" =~ $_re ]]; then
602+
fn_name="${BASH_REMATCH[2]}"
603+
else
604+
# Match: function name { (keyword form, no parens)
578605
_re='^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$'
579-
fn_name=$(echo "$line" | sed -nE "s/$_re/\2/p")
606+
if [[ "$line" =~ $_re ]]; then
607+
fn_name="${BASH_REMATCH[2]}"
608+
fi
580609
fi
581610

582611
if [ -n "$fn_name" ]; then
@@ -588,10 +617,12 @@ function bashunit::coverage::extract_functions() {
588617
# Count opening braces on this line
589618
local open_braces="${line//[^\{]/}"
590619
local close_braces="${line//[^\}]/}"
591-
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
620+
local open_count=${#open_braces}
621+
local close_count=${#close_braces}
622+
brace_count=$((brace_count + open_count - close_count))
592623

593-
# Single-line function
594-
if [ "$brace_count" -eq 0 ] && [ "$(echo "$line" | "$GREP" -c '\{' || true)" -gt 0 ] && [ "$(echo "$line" | "$GREP" -c '\}' || true)" -gt 0 ]; then
624+
# Single-line function: braces balance on same line and both present
625+
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
595626
echo "${current_fn}:${fn_start}:${lineno}"
596627
in_function=0
597628
current_fn=""
@@ -694,11 +725,14 @@ function bashunit::coverage::report_text() {
694725
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
695726
has_files=true
696727

697-
local executable hit pct class
698-
executable=$(bashunit::coverage::get_executable_lines "$file")
699-
hit=$(bashunit::coverage::get_hit_lines "$file")
700-
pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable")
701-
class=$(bashunit::coverage::get_coverage_class "$pct")
728+
local stats executable hit pct class
729+
stats=$(bashunit::coverage::get_file_stats "$file")
730+
executable="${stats%%:*}"
731+
stats="${stats#*:}"
732+
hit="${stats%%:*}"
733+
stats="${stats#*:}"
734+
pct="${stats%%:*}"
735+
class="${stats##*:}"
702736

703737
total_executable=$((total_executable + executable))
704738
total_hit=$((total_hit + hit))
@@ -772,19 +806,23 @@ function bashunit::coverage::report_lcov() {
772806

773807
echo "SF:$file"
774808

775-
local lineno=0
776-
local line
809+
local -a hits_by_line=()
810+
local hit_lineno hit_count
811+
while IFS=: read -r hit_lineno hit_count; do
812+
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
813+
done < <(bashunit::coverage::get_all_line_hits "$file")
814+
815+
local lineno=0 executable=0 hit=0 line line_hits
777816
# shellcheck disable=SC2094
778817
while IFS= read -r line || [ -n "$line" ]; do
779-
((++lineno))
818+
lineno=$((lineno + 1))
780819
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
781-
echo "DA:${lineno},$(bashunit::coverage::get_line_hits "$file" "$lineno")"
820+
executable=$((executable + 1))
821+
line_hits=${hits_by_line[lineno]:-0}
822+
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
823+
echo "DA:${lineno},${line_hits}"
782824
done <"$file"
783825

784-
local executable hit
785-
executable=$(bashunit::coverage::get_executable_lines "$file")
786-
hit=$(bashunit::coverage::get_hit_lines "$file")
787-
788826
echo "LF:$executable"
789827
echo "LH:$hit"
790828
echo "end_of_record"
@@ -852,10 +890,13 @@ function bashunit::coverage::report_html() {
852890
while IFS= read -r file; do
853891
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
854892

855-
local executable hit pct
856-
executable=$(bashunit::coverage::get_executable_lines "$file")
857-
hit=$(bashunit::coverage::get_hit_lines "$file")
858-
pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable")
893+
local stats executable hit pct
894+
stats=$(bashunit::coverage::get_file_stats "$file")
895+
executable="${stats%%:*}"
896+
stats="${stats#*:}"
897+
hit="${stats%%:*}"
898+
stats="${stats#*:}"
899+
pct="${stats%%:*}"
859900

860901
total_executable=$((total_executable + executable))
861902
total_hit=$((total_hit + hit))

tests/unit/coverage_reporting_test.sh

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,75 @@ function test_coverage_get_hit_lines_returns_zero_when_no_data() {
250250

251251
assert_equals "0" "$result"
252252
}
253+
254+
function test_coverage_compute_file_coverage_returns_executable_and_hit_counts() {
255+
BASHUNIT_COVERAGE="true"
256+
bashunit::coverage::init
257+
258+
local temp_file
259+
temp_file=$(mktemp)
260+
cat >"$temp_file" <<'EOF'
261+
#!/usr/bin/env bash
262+
echo "line 1"
263+
echo "line 2"
264+
echo "line 3"
265+
EOF
266+
267+
{
268+
echo "${temp_file}:2"
269+
echo "${temp_file}:3"
270+
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
271+
272+
local result
273+
result=$(bashunit::coverage::compute_file_coverage "$temp_file")
274+
275+
assert_equals "3:2" "$result"
276+
277+
rm -f "$temp_file"
278+
}
279+
280+
function test_coverage_compute_file_coverage_zero_hits() {
281+
BASHUNIT_COVERAGE="true"
282+
bashunit::coverage::init
283+
284+
local temp_file
285+
temp_file=$(mktemp)
286+
cat >"$temp_file" <<'EOF'
287+
#!/usr/bin/env bash
288+
echo "line 1"
289+
echo "line 2"
290+
EOF
291+
292+
local result
293+
result=$(bashunit::coverage::compute_file_coverage "$temp_file")
294+
295+
assert_equals "2:0" "$result"
296+
297+
rm -f "$temp_file"
298+
}
299+
300+
function test_coverage_compute_file_coverage_ignores_non_executable_hits() {
301+
BASHUNIT_COVERAGE="true"
302+
bashunit::coverage::init
303+
304+
local temp_file
305+
temp_file=$(mktemp)
306+
cat >"$temp_file" <<'EOF'
307+
#!/usr/bin/env bash
308+
# comment
309+
echo "line 3"
310+
EOF
311+
312+
{
313+
echo "${temp_file}:1"
314+
echo "${temp_file}:2"
315+
echo "${temp_file}:3"
316+
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
317+
318+
local result
319+
result=$(bashunit::coverage::compute_file_coverage "$temp_file")
320+
321+
assert_equals "1:1" "$result"
322+
323+
rm -f "$temp_file"
324+
}

0 commit comments

Comments
 (0)