Skip to content

Commit 20ab298

Browse files
objctpChemaclass
andauthored
Further profiling and optimisation #636 (#647)
Co-authored-by: Chemaclass <chemaclass@outlook.es>
1 parent 75a7ee8 commit 20ab298

4 files changed

Lines changed: 278 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- 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)
1212
- 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)
1313
- Replace `echo | sed` / `echo | grep` subshells in `bashunit::coverage::extract_functions` with bash native regex matching and parameter expansion (#636)
14+
- Speed up coverage report generation by replacing per-line `sed` lookups with pre-loaded indexed arrays in `get_hit_lines` and `generate_file_html` (#636)
15+
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)
1416

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

src/coverage.sh

Lines changed: 201 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
9999
_BASHUNIT_COVERAGE_TRACK_CACHE=""
100100
_BASHUNIT_COVERAGE_PATH_CACHE=""
101101
_BASHUNIT_COVERAGE_IS_PARALLEL=""
102+
_BASHUNIT_COVERAGE_STATS_FILES=()
103+
_BASHUNIT_COVERAGE_STATS_EXEC=()
104+
_BASHUNIT_COVERAGE_STATS_HIT=()
105+
_BASHUNIT_COVERAGE_STATS_PCT=()
106+
_BASHUNIT_COVERAGE_STATS_CLASS=()
107+
_BASHUNIT_COVERAGE_STATS_COUNT=0
108+
_BASHUNIT_COVERAGE_STATS_LOOKUP=""
102109

103110
export _BASHUNIT_COVERAGE_DATA_FILE
104111
export _BASHUNIT_COVERAGE_TRACKED_FILES
@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
190197
echo "${executable}:${hit}:${pct}:${class}"
191198
}
192199

200+
# Pre-computed file stats cache (avoids redundant per-file reads across reports)
201+
_BASHUNIT_COVERAGE_STATS_FILES=()
202+
_BASHUNIT_COVERAGE_STATS_EXEC=()
203+
_BASHUNIT_COVERAGE_STATS_HIT=()
204+
_BASHUNIT_COVERAGE_STATS_PCT=()
205+
_BASHUNIT_COVERAGE_STATS_CLASS=()
206+
_BASHUNIT_COVERAGE_STATS_COUNT=0
207+
_BASHUNIT_COVERAGE_STATS_LOOKUP=""
208+
209+
# Pre-compute stats for all tracked files (call once before reports)
210+
function bashunit::coverage::precompute_file_stats() {
211+
_BASHUNIT_COVERAGE_STATS_FILES=()
212+
_BASHUNIT_COVERAGE_STATS_EXEC=()
213+
_BASHUNIT_COVERAGE_STATS_HIT=()
214+
_BASHUNIT_COVERAGE_STATS_PCT=()
215+
_BASHUNIT_COVERAGE_STATS_CLASS=()
216+
_BASHUNIT_COVERAGE_STATS_COUNT=0
217+
_BASHUNIT_COVERAGE_STATS_LOOKUP=""
218+
219+
local file
220+
while IFS= read -r file; do
221+
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
222+
223+
local stats executable hit pct class
224+
stats=$(bashunit::coverage::compute_file_coverage "$file")
225+
executable="${stats%%:*}"
226+
hit="${stats##*:}"
227+
pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable")
228+
class=$(bashunit::coverage::get_coverage_class "$pct")
229+
230+
local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
231+
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
232+
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
233+
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
234+
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
235+
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
236+
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
237+
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
238+
done < <(bashunit::coverage::get_tracked_files)
239+
}
240+
241+
# Look up cached stats for a file, returns "executable:hit:pct:class"
242+
function bashunit::coverage::get_cached_stats() {
243+
local file="$1"
244+
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
245+
*"|${file}="*)
246+
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
247+
idx="${idx%%"|"*}"
248+
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
249+
return 0
250+
;;
251+
esac
252+
bashunit::coverage::get_file_stats "$file"
253+
}
254+
193255
function bashunit::coverage::record_line() {
194256
local file="$1"
195257
local lineno="$2"
@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
459521
# Skip empty lines (line with only whitespace) — built-in, no subshell
460522
[ -z "${line// /}" ] && return 1
461523

462-
# Single combined grep covers every non-executable pattern
463-
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
524+
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
525+
local stripped="${line#"${line%%[![:space:]]*}"}"
526+
local _trail="${stripped##*[![:space:]]}"
527+
local trimmed="${stripped%"$_trail"}"
528+
529+
case "$trimmed" in
530+
'#'*) return 1 ;; # Comments (including shebang)
531+
'{' | '}') return 1 ;; # Braces only
532+
esac
533+
534+
local first="${trimmed%%[[:space:]]*}"
535+
case "$first" in
536+
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
537+
local rest="${trimmed#"$first"}"
538+
local _rl="${rest%%[![:space:]]*}"
539+
rest="${rest#"$_rl"}"
540+
case "$rest" in '' | '#'*) return 1 ;; esac
541+
;;
542+
esac
543+
544+
# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
545+
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
464546

465547
return 0
466548
}
@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {
499581

500582
# Only count hits that correspond to executable lines
501583
# This prevents >100% coverage when DEBUG trap fires on non-executable lines
584+
585+
# Pre-load file lines into indexed array (avoids sed per line)
586+
local -a file_lines=()
587+
local _idx=0 _fl
588+
while IFS= read -r _fl || [ -n "$_fl" ]; do
589+
file_lines[_idx]="$_fl"
590+
((++_idx))
591+
done <"$file"
592+
502593
local count=0
503594
local line_num
504595
for line_num in $hit_lines; do
505-
local line_content
506-
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
596+
local line_content="${file_lines[$((line_num - 1))]:-}"
597+
[ -z "$line_content" ] && continue
507598
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
508599
((++count))
509600
fi
@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
539630
done < <(bashunit::coverage::get_all_line_hits "$file")
540631

541632
local executable=0 hit=0 lineno=0 line line_hits
542-
while IFS= read -r line || [ -n "$line" ]; do
543-
lineno=$((lineno + 1))
633+
local -a cv_lines=()
634+
local _cli=0 _cl
635+
while IFS= read -r _cl || [ -n "$_cl" ]; do
636+
cv_lines[_cli]="$_cl"
637+
((++_cli))
638+
done <"$file"
639+
640+
for line in "${cv_lines[@]}"; do
641+
((++lineno))
544642
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
545-
executable=$((executable + 1))
643+
((++executable))
546644
line_hits=${hits_by_line[lineno]:-0}
547-
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
548-
done <"$file"
645+
[ "$line_hits" -gt 0 ] && ((++hit))
646+
done
549647

550648
echo "${executable}:${hit}"
551649
}
@@ -604,16 +702,33 @@ function bashunit::coverage::extract_functions() {
604702
if [ "$in_function" -eq 0 ]; then
605703
local fn_name=""
606704

607-
# Match: name() with optional `function` keyword (parens form)
608-
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
609-
if [[ "$line" =~ $_re ]]; then
610-
fn_name="${BASH_REMATCH[2]}"
611-
else
612-
# Match: function name { (keyword form, no parens)
613-
_re='^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$'
614-
if [[ "$line" =~ $_re ]]; then
615-
fn_name="${BASH_REMATCH[2]}"
616-
fi
705+
# Extract function name using pure Bash string operations (avoids sed subshell)
706+
local stripped="${line#"${line%%[![:space:]]*}"}"
707+
708+
# Strip "function " prefix if present
709+
case "$stripped" in
710+
function[\ \ ]*)
711+
stripped="${stripped#function}"
712+
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
713+
;;
714+
esac
715+
716+
# Extract first word as candidate function name
717+
fn_name="${stripped%%[[:space:]\(\{]*}"
718+
719+
# Validate: must start with valid identifier char, and rest must have () or {
720+
if [ -n "$fn_name" ]; then
721+
case "$fn_name" in
722+
[a-zA-Z_]*)
723+
local after_name="${stripped#"$fn_name"}"
724+
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
725+
case "$after_name" in
726+
'()'* | '{'*) ;;
727+
*) fn_name="" ;;
728+
esac
729+
;;
730+
*) fn_name="" ;;
731+
esac
617732
fi
618733

619734
if [ -n "$fn_name" ]; then
@@ -631,7 +746,7 @@ function bashunit::coverage::extract_functions() {
631746

632747
# Single-line function: braces balance on same line and both present
633748
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
634-
echo "${current_fn}:${fn_start}:${lineno}"
749+
echo "${current_fn}|${fn_start}|${lineno}"
635750
in_function=0
636751
current_fn=""
637752
fi
@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
676791
local hit=0
677792
local lineno=0
678793

794+
# Pre-load file lines into indexed array (avoids sed per line)
795+
local -a fn_lines=()
796+
local _fli=0 _fl
797+
while IFS= read -r _fl || [ -n "$_fl" ]; do
798+
fn_lines[_fli]="$_fl"
799+
((++_fli))
800+
done <"$file"
801+
679802
for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
680-
local line_content
681-
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
803+
local line_content="${fn_lines[$((lineno - 1))]:-}"
682804

683805
if bashunit::coverage::is_executable_line "$line_content" "$lineno"; then
684806
((++executable))
@@ -701,16 +823,24 @@ function bashunit::coverage::get_percentage() {
701823
local total_executable=0
702824
local total_hit=0
703825

704-
while IFS= read -r file; do
705-
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
826+
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
827+
local i
828+
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
829+
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
830+
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
831+
done
832+
else
833+
while IFS= read -r file; do
834+
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
706835

707-
local executable hit
708-
executable=$(bashunit::coverage::get_executable_lines "$file")
709-
hit=$(bashunit::coverage::get_hit_lines "$file")
836+
local executable hit
837+
executable=$(bashunit::coverage::get_executable_lines "$file")
838+
hit=$(bashunit::coverage::get_hit_lines "$file")
710839

711-
total_executable=$((total_executable + executable))
712-
total_hit=$((total_hit + hit))
713-
done < <(bashunit::coverage::get_tracked_files)
840+
total_executable=$((total_executable + executable))
841+
total_hit=$((total_hit + hit))
842+
done < <(bashunit::coverage::get_tracked_files)
843+
fi
714844

715845
bashunit::coverage::calculate_percentage "$total_hit" "$total_executable"
716846
}
@@ -733,14 +863,14 @@ function bashunit::coverage::report_text() {
733863
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
734864
has_files=true
735865

736-
local stats executable hit pct class
737-
stats=$(bashunit::coverage::get_file_stats "$file")
866+
local executable hit pct class stats rest
867+
stats=$(bashunit::coverage::get_cached_stats "$file")
738868
executable="${stats%%:*}"
739-
stats="${stats#*:}"
740-
hit="${stats%%:*}"
741-
stats="${stats#*:}"
742-
pct="${stats%%:*}"
743-
class="${stats##*:}"
869+
rest="${stats#*:}"
870+
hit="${rest%%:*}"
871+
rest="${rest#*:}"
872+
pct="${rest%%:*}"
873+
class="${rest#*:}"
744874

745875
total_executable=$((total_executable + executable))
746876
total_hit=$((total_hit + hit))
@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
806936
done < <(bashunit::coverage::get_all_line_hits "$file")
807937

808938
local lineno=0 executable=0 hit=0 line line_hits
809-
# shellcheck disable=SC2094
810-
while IFS= read -r line || [ -n "$line" ]; do
811-
lineno=$((lineno + 1))
812-
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
813-
executable=$((executable + 1))
814-
line_hits=${hits_by_line[lineno]:-0}
815-
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
816-
echo "DA:${lineno},${line_hits}"
939+
local -a lcov_lines=()
940+
local _lli=0 _ll
941+
while IFS= read -r _ll || [ -n "$_ll" ]; do
942+
lcov_lines[_lli]="$_ll"
943+
((++_lli))
817944
done <"$file"
818945

946+
for line in "${lcov_lines[@]}"; do
947+
((++lineno))
948+
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
949+
((++executable))
950+
local lh="${hits_by_line[$lineno]:-0}"
951+
[ "$lh" -gt 0 ] && ((++hit))
952+
echo "DA:${lineno},${lh}"
953+
done
954+
819955
echo "LF:$executable"
820956
echo "LH:$hit"
821957
echo "end_of_record"
@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
8781014
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
8791015

8801016
local stats executable hit pct
881-
stats=$(bashunit::coverage::get_file_stats "$file")
1017+
stats=$(bashunit::coverage::get_cached_stats "$file")
8821018
executable="${stats%%:*}"
8831019
stats="${stats#*:}"
8841020
hit="${stats%%:*}"
@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
12851421
local output_file="$2"
12861422

12871423
local display_file="${file#"$(pwd)"/}"
1288-
local executable hit pct class
1289-
executable=$(bashunit::coverage::get_executable_lines "$file")
1290-
hit=$(bashunit::coverage::get_hit_lines "$file")
1291-
pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable")
1292-
class=$(bashunit::coverage::get_coverage_class "$pct")
1424+
local executable hit pct class stats rest
1425+
stats=$(bashunit::coverage::get_cached_stats "$file")
1426+
executable="${stats%%:*}"
1427+
rest="${stats#*:}"
1428+
hit="${rest%%:*}"
1429+
rest="${rest#*:}"
1430+
pct="${rest%%:*}"
1431+
class="${rest#*:}"
12931432
local uncovered=$((executable - hit))
12941433

12951434
# Pre-load all line hits into indexed array (performance optimization)
@@ -1299,6 +1438,14 @@ function bashunit::coverage::generate_file_html() {
12991438
hits_by_line[_ln]=$_cnt
13001439
done < <(bashunit::coverage::get_all_line_hits "$file")
13011440

1441+
# Pre-load all file lines into indexed array (avoids sed per line)
1442+
local -a file_lines=()
1443+
local _fli=0 _fl
1444+
while IFS= read -r _fl || [ -n "$_fl" ]; do
1445+
file_lines[_fli]="$_fl"
1446+
((++_fli))
1447+
done <"$file"
1448+
13021449
# Pre-load test hits data into indexed array (for tooltips)
13031450
# Index: line number, Value: newline-separated list of "test_file:test_function"
13041451
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
@@ -1567,7 +1714,7 @@ EOF
15671714
local ln
15681715
for ((ln = fn_start; ln <= fn_end; ln++)); do
15691716
local ln_content
1570-
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
1717+
ln_content="${file_lines[$((ln - 1))]:-}"
15711718
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
15721719
((++fn_executable))
15731720
local ln_hits=${hits_by_line[$ln]:-0}
@@ -1622,7 +1769,7 @@ EOF
16221769

16231770
local lineno=0
16241771
local line
1625-
while IFS= read -r line || [ -n "$line" ]; do
1772+
for line in "${file_lines[@]}"; do
16261773
((++lineno))
16271774

16281775
local escaped_line
@@ -1666,7 +1813,7 @@ EOF
16661813
echo " <td class=\"hits\">$hits_display</td>"
16671814
echo " <td class=\"code\">$escaped_line</td>"
16681815
echo " </tr>"
1669-
done <"$file"
1816+
done
16701817

16711818
cat <<'EOF'
16721819
</table>

0 commit comments

Comments
 (0)