From 79b6066624de3155070f306c9566d7d138ca546c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 29 Apr 2026 12:06:24 +0200 Subject: [PATCH 1/2] perf(coverage): single-pass file scan + native regex in extract_functions Address remaining review comments from #636: - Add bashunit::coverage::compute_file_coverage that scans each source file once and resolves hits via the existing single-pass get_all_line_hits helper, removing the O(lines x data) re-scan from report_lcov. - Route get_file_stats, report_text and report_html through the new helper so per-file executable + hit counts are computed once instead of via two independent file scans plus repeated coverage-data greps. - Replace echo | sed and echo | grep subshells in extract_functions with bash native regex matching (BASH_REMATCH) and parameter expansion-based brace counting. --- CHANGELOG.md | 2 + src/coverage.sh | 95 +++++++++++++++++++-------- tests/unit/coverage_reporting_test.sh | 68 +++++++++++++++++++ 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23154182..2456d7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Changed - 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) +- 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) +- Replace `echo | sed` / `echo | grep` subshells in `bashunit::coverage::extract_functions` with bash native regex matching and parameter expansion (#636) ## [0.35.0](https://github.com/TypedDevs/bashunit/compare/0.34.1...0.35.0) - 2026-04-26 diff --git a/src/coverage.sh b/src/coverage.sh index dcc17e04..1d6f7045 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -173,9 +173,10 @@ function bashunit::coverage::calculate_percentage() { # Get file coverage stats as "executable:hit:pct:class" function bashunit::coverage::get_file_stats() { local file="$1" - local executable hit pct class - executable=$(bashunit::coverage::get_executable_lines "$file") - hit=$(bashunit::coverage::get_hit_lines "$file") + local stats executable hit pct class + stats=$(bashunit::coverage::compute_file_coverage "$file") + executable="${stats%%:*}" + hit="${stats##*:}" pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") class=$(bashunit::coverage::get_coverage_class "$pct") echo "${executable}:${hit}:${pct}:${class}" @@ -517,6 +518,30 @@ function bashunit::coverage::get_line_hits() { echo "$count" } +# Compute executable + hit counts for a file in a single source-file pass. +# Reuses get_all_line_hits to avoid scanning the coverage data per line. +# Output format: "executable:hit" +function bashunit::coverage::compute_file_coverage() { + local file="$1" + + local -a hits_by_line=() + local hit_lineno hit_count + while IFS=: read -r hit_lineno hit_count; do + [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count + done < <(bashunit::coverage::get_all_line_hits "$file") + + local executable=0 hit=0 lineno=0 line line_hits + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + bashunit::coverage::is_executable_line "$line" "$lineno" || continue + executable=$((executable + 1)) + line_hits=${hits_by_line[lineno]:-0} + [ "$line_hits" -gt 0 ] && hit=$((hit + 1)) + done <"$file" + + echo "${executable}:${hit}" +} + # Get all line hits for a file in one pass (performance optimization) # Output format: one "lineno:count" per line function bashunit::coverage::get_all_line_hits() { @@ -571,12 +596,16 @@ function bashunit::coverage::extract_functions() { if [ "$in_function" -eq 0 ]; then local fn_name="" - # Match: function name() or function name { + # Match: name() with optional `function` keyword (parens form) local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$' - fn_name=$(echo "$line" | sed -nE "s/$_re/\2/p") - if [ -z "$fn_name" ]; then + if [[ "$line" =~ $_re ]]; then + fn_name="${BASH_REMATCH[2]}" + else + # Match: function name { (keyword form, no parens) _re='^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$' - fn_name=$(echo "$line" | sed -nE "s/$_re/\2/p") + if [[ "$line" =~ $_re ]]; then + fn_name="${BASH_REMATCH[2]}" + fi fi if [ -n "$fn_name" ]; then @@ -588,10 +617,12 @@ function bashunit::coverage::extract_functions() { # Count opening braces on this line local open_braces="${line//[^\{]/}" local close_braces="${line//[^\}]/}" - brace_count=$((brace_count + ${#open_braces} - ${#close_braces})) + local open_count=${#open_braces} + local close_count=${#close_braces} + brace_count=$((brace_count + open_count - close_count)) - # Single-line function - if [ "$brace_count" -eq 0 ] && [ "$(echo "$line" | "$GREP" -c '\{' || true)" -gt 0 ] && [ "$(echo "$line" | "$GREP" -c '\}' || true)" -gt 0 ]; then + # Single-line function: braces balance on same line and both present + if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then echo "${current_fn}:${fn_start}:${lineno}" in_function=0 current_fn="" @@ -694,11 +725,14 @@ function bashunit::coverage::report_text() { { [ -z "$file" ] || [ ! -f "$file" ]; } && continue has_files=true - local executable hit pct class - executable=$(bashunit::coverage::get_executable_lines "$file") - hit=$(bashunit::coverage::get_hit_lines "$file") - pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") - class=$(bashunit::coverage::get_coverage_class "$pct") + local stats executable hit pct class + stats=$(bashunit::coverage::get_file_stats "$file") + executable="${stats%%:*}" + stats="${stats#*:}" + hit="${stats%%:*}" + stats="${stats#*:}" + pct="${stats%%:*}" + class="${stats##*:}" total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) @@ -772,19 +806,23 @@ function bashunit::coverage::report_lcov() { echo "SF:$file" - local lineno=0 - local line + local -a hits_by_line=() + local hit_lineno hit_count + while IFS=: read -r hit_lineno hit_count; do + [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count + done < <(bashunit::coverage::get_all_line_hits "$file") + + local lineno=0 executable=0 hit=0 line line_hits # shellcheck disable=SC2094 while IFS= read -r line || [ -n "$line" ]; do - ((++lineno)) + lineno=$((lineno + 1)) bashunit::coverage::is_executable_line "$line" "$lineno" || continue - echo "DA:${lineno},$(bashunit::coverage::get_line_hits "$file" "$lineno")" + executable=$((executable + 1)) + line_hits=${hits_by_line[lineno]:-0} + [ "$line_hits" -gt 0 ] && hit=$((hit + 1)) + echo "DA:${lineno},${line_hits}" done <"$file" - local executable hit - executable=$(bashunit::coverage::get_executable_lines "$file") - hit=$(bashunit::coverage::get_hit_lines "$file") - echo "LF:$executable" echo "LH:$hit" echo "end_of_record" @@ -852,10 +890,13 @@ function bashunit::coverage::report_html() { while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - local executable hit pct - executable=$(bashunit::coverage::get_executable_lines "$file") - hit=$(bashunit::coverage::get_hit_lines "$file") - pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") + local stats executable hit pct + stats=$(bashunit::coverage::get_file_stats "$file") + executable="${stats%%:*}" + stats="${stats#*:}" + hit="${stats%%:*}" + stats="${stats#*:}" + pct="${stats%%:*}" total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index c0ecb5bd..62716be2 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -250,3 +250,71 @@ function test_coverage_get_hit_lines_returns_zero_when_no_data() { assert_equals "0" "$result" } + +function test_coverage_compute_file_coverage_returns_executable_and_hit_counts() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "line 1" +echo "line 2" +echo "line 3" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_file_coverage "$temp_file") + + assert_equals "3:2" "$result" + + rm -f "$temp_file" +} + +function test_coverage_compute_file_coverage_zero_hits() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "line 1" +echo "line 2" +EOF + + local result + result=$(bashunit::coverage::compute_file_coverage "$temp_file") + + assert_equals "2:0" "$result" + + rm -f "$temp_file" +} + +function test_coverage_compute_file_coverage_ignores_non_executable_hits() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +# comment +echo "line 3" +EOF + + echo "${temp_file}:1" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_file_coverage "$temp_file") + + assert_equals "1:1" "$result" + + rm -f "$temp_file" +} From 1024a4395d951076293761380355909d42f15cd6 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 29 Apr 2026 12:09:45 +0200 Subject: [PATCH 2/2] test(coverage): collapse consecutive >> appends to satisfy SC2129 --- tests/unit/coverage_reporting_test.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index 62716be2..2687fd2b 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -264,8 +264,10 @@ echo "line 2" echo "line 3" EOF - echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" - echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + { + echo "${temp_file}:2" + echo "${temp_file}:3" + } >>"$_BASHUNIT_COVERAGE_DATA_FILE" local result result=$(bashunit::coverage::compute_file_coverage "$temp_file") @@ -307,9 +309,11 @@ function test_coverage_compute_file_coverage_ignores_non_executable_hits() { echo "line 3" EOF - echo "${temp_file}:1" >>"$_BASHUNIT_COVERAGE_DATA_FILE" - echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" - echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + { + echo "${temp_file}:1" + echo "${temp_file}:2" + echo "${temp_file}:3" + } >>"$_BASHUNIT_COVERAGE_DATA_FILE" local result result=$(bashunit::coverage::compute_file_coverage "$temp_file")