Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,8 @@
- 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)
- 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)
- Speed up coverage report generation by caching pre-computed file stats across text/lcov/html reports (#636)

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

Expand Down
255 changes: 201 additions & 54 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,13 @@ function bashunit::coverage::init() {
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
Expand DownExpand Up@@ -190,6 +197,61 @@ function bashunit::coverage::get_file_stats() {
echo "${executable}:${hit}:${pct}:${class}"
}

# Pre-computed file stats cache (avoids redundant per-file reads across reports)
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

# Pre-compute stats for all tracked files (call once before reports)
function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_FILES=()
_BASHUNIT_COVERAGE_STATS_EXEC=()
_BASHUNIT_COVERAGE_STATS_HIT=()
_BASHUNIT_COVERAGE_STATS_PCT=()
_BASHUNIT_COVERAGE_STATS_CLASS=()
_BASHUNIT_COVERAGE_STATS_COUNT=0
_BASHUNIT_COVERAGE_STATS_LOOKUP=""

local file
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

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")

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
_BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|"
done < <(bashunit::coverage::get_tracked_files)
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in
*"|${file}="*)
local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}"
idx="${idx%%"|"*}"
echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}"
return 0
;;
esac
bashunit::coverage::get_file_stats "$file"
}

function bashunit::coverage::record_line() {
local file="$1"
local lineno="$2"
Expand DownExpand Up@@ -459,8 +521,28 @@ function bashunit::coverage::is_executable_line() {
# Skip empty lines (line with only whitespace) — built-in, no subshell
[ -z "${line// /}" ] && return 1

# Single combined grep covers every non-executable pattern
[ "$(echo "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1
# Fast path: pure Bash checks for common non-executable patterns (no subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"
local _trail="${stripped##*[![:space:]]}"
local trimmed="${stripped%"$_trail"}"

case "$trimmed" in
'#'*) return 1 ;; # Comments (including shebang)
'{' | '}') return 1 ;; # Braces only
esac

local first="${trimmed%%[[:space:]]*}"
case "$first" in
'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')')
local rest="${trimmed#"$first"}"
local _rl="${rest%%[![:space:]]*}"
rest="${rest#"$_rl"}"
case "$rest" in '' | '#'*) return 1 ;; esac
;;
esac

# Fallback: grep for complex patterns (function declarations, case patterns, done+redirection)
[ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1

return 0
}
Expand DownExpand Up@@ -499,11 +581,20 @@ function bashunit::coverage::get_hit_lines() {

# Only count hits that correspond to executable lines
# This prevents >100% coverage when DEBUG trap fires on non-executable lines

# Pre-load file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _idx=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_idx]="$_fl"
((++_idx))
done <"$file"

local count=0
local line_num
for line_num in $hit_lines; do
local line_content
line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue
local line_content="${file_lines[$((line_num - 1))]:-}"
[ -z "$line_content" ] && continue
if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then
((++count))
fi
Expand DownExpand Up@@ -539,13 +630,20 @@ function bashunit::coverage::compute_file_coverage() {
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))
local -a cv_lines=()
local _cli=0 _cl
while IFS= read -r _cl || [ -n "$_cl" ]; do
cv_lines[_cli]="$_cl"
((++_cli))
done <"$file"

for line in "${cv_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
executable=$((executable + 1))
((++executable))
line_hits=${hits_by_line[lineno]:-0}
[ "$line_hits" -gt 0 ] && hit=$((hit + 1))
done <"$file"
[ "$line_hits" -gt 0 ] && ((++hit))
done

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

# Match: name() with optional `function` keyword (parens form)
local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$'
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:]]*(#.*)?$'
if [[ "$line" =~ $_re ]]; then
fn_name="${BASH_REMATCH[2]}"
fi
# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"

# Validate: must start with valid identifier char, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi

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

# 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}"
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
Expand DownExpand Up@@ -676,9 +791,16 @@ function bashunit::coverage::get_function_coverage() {
local hit=0
local lineno=0

# Pre-load file lines into indexed array (avoids sed per line)
local -a fn_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
fn_lines[_fli]="$_fl"
((++_fli))
done <"$file"

for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
local line_content
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
local line_content="${fn_lines[$((lineno - 1))]:-}"

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

while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then
local i
for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do
total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i]))
total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i]))
done
else
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")
local executable hit
executable=$(bashunit::coverage::get_executable_lines "$file")
hit=$(bashunit::coverage::get_hit_lines "$file")

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
done < <(bashunit::coverage::get_tracked_files)
fi

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

local stats executable hit pct class
stats=$(bashunit::coverage::get_file_stats "$file")
local executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
stats="${stats#*:}"
pct="${stats%%:*}"
class="${stats##*:}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"

total_executable=$((total_executable + executable))
total_hit=$((total_hit + hit))
Expand DownExpand Up@@ -806,16 +936,22 @@ function bashunit::coverage::report_lcov() {
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 + 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))
echo "DA:${lineno},${line_hits}"
local -a lcov_lines=()
local _lli=0 _ll
while IFS= read -r _ll || [ -n "$_ll" ]; do
lcov_lines[_lli]="$_ll"
((++_lli))
done <"$file"

for line in "${lcov_lines[@]}"; do
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done

echo "LF:$executable"
echo "LH:$hit"
echo "end_of_record"
Expand DownExpand Up@@ -878,7 +1014,7 @@ function bashunit::coverage::report_html() {
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local stats executable hit pct
stats=$(bashunit::coverage::get_file_stats "$file")
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
stats="${stats#*:}"
hit="${stats%%:*}"
Expand DownExpand Up@@ -1285,11 +1421,14 @@ function bashunit::coverage::generate_file_html() {
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
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 executable hit pct class stats rest
stats=$(bashunit::coverage::get_cached_stats "$file")
executable="${stats%%:*}"
rest="${stats#*:}"
hit="${rest%%:*}"
rest="${rest#*:}"
pct="${rest%%:*}"
class="${rest#*:}"
local uncovered=$((executable - hit))

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

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
local _fli=0 _fl
while IFS= read -r _fl || [ -n "$_fl" ]; do
file_lines[_fli]="$_fl"
((++_fli))
done <"$file"

# Pre-load test hits data into indexed array (for tooltips)
# Index: line number, Value: newline-separated list of "test_file:test_function"
# Using indexed array for Bash 3.0 compatibility (no associative arrays)
Expand DownExpand Up@@ -1567,7 +1714,7 @@ EOF
local ln
for ((ln = fn_start; ln <= fn_end; ln++)); do
local ln_content
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
Expand DownExpand Up@@ -1622,7 +1769,7 @@ EOF

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

local escaped_line
Expand DownExpand Up@@ -1666,7 +1813,7 @@ EOF
echo " <td class=\"hits\">$hits_display</td>"
echo " <td class=\"code\">$escaped_line</td>"
echo " </tr>"
done <"$file"
done

cat <<'EOF'
</table>
Expand Down
Loading
Loading