Skip to content

Commit 28a8c18

Browse files
authored
Merge pull request #572 from TypedDevs/code-coverage-improvements
Code coverage improvements
2 parents 6c0b975 + 8bda8c0 commit 28a8c18

1 file changed

Lines changed: 249 additions & 4 deletions

File tree

src/coverage.sh

Lines changed: 249 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,112 @@ function bashunit::coverage::get_all_line_tests() {
394394
sed "s|^${file}:||" | sort -u
395395
}
396396

397+
# Extract function definitions from a bash file
398+
# Output format: function_name:start_line:end_line (one per function)
399+
function bashunit::coverage::extract_functions() {
400+
local file="$1"
401+
402+
local lineno=0
403+
local in_function=0
404+
local brace_count=0
405+
local current_fn=""
406+
local fn_start=0
407+
408+
while IFS= read -r line || [[ -n "$line" ]]; do
409+
((lineno++))
410+
411+
# Check for function definition patterns
412+
# Pattern 1: function name() { or function name {
413+
# Pattern 2: name() { or name () {
414+
if [[ $in_function -eq 0 ]]; then
415+
local fn_name=""
416+
417+
# Match: function name() or function name {
418+
if [[ "$line" =~ ^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$ ]]; then
419+
fn_name="${BASH_REMATCH[2]}"
420+
elif [[ "$line" =~ ^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$ ]]; then
421+
fn_name="${BASH_REMATCH[2]}"
422+
fi
423+
424+
if [[ -n "$fn_name" ]]; then
425+
in_function=1
426+
current_fn="$fn_name"
427+
fn_start=$lineno
428+
brace_count=0
429+
430+
# Count opening braces on this line
431+
local open_braces="${line//[^\{]/}"
432+
local close_braces="${line//[^\}]/}"
433+
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
434+
435+
# Single-line function
436+
if [[ $brace_count -eq 0 && "$line" =~ \{ && "$line" =~ \} ]]; then
437+
echo "${current_fn}:${fn_start}:${lineno}"
438+
in_function=0
439+
current_fn=""
440+
fi
441+
continue
442+
fi
443+
fi
444+
445+
# Track braces inside function
446+
if [[ $in_function -eq 1 ]]; then
447+
local open_braces="${line//[^\{]/}"
448+
local close_braces="${line//[^\}]/}"
449+
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
450+
451+
# Function ended
452+
if [[ $brace_count -le 0 ]]; then
453+
echo "${current_fn}:${fn_start}:${lineno}"
454+
in_function=0
455+
current_fn=""
456+
brace_count=0
457+
fi
458+
fi
459+
done < "$file"
460+
461+
# Handle unclosed function (shouldn't happen in valid code)
462+
if [[ $in_function -eq 1 && -n "$current_fn" ]]; then
463+
echo "${current_fn}:${fn_start}:${lineno}"
464+
fi
465+
}
466+
467+
# Calculate coverage for a specific function in a file
468+
# Returns: hit_lines:executable_lines:percentage
469+
function bashunit::coverage::get_function_coverage() {
470+
local file="$1"
471+
local fn_start="$2"
472+
local fn_end="$3"
473+
shift 3
474+
475+
# Accept hits_by_line array as nameref (Bash 4.3+) or fall back to counting
476+
local -n _hits_ref=$1 2>/dev/null || true
477+
478+
local executable=0
479+
local hit=0
480+
local lineno
481+
482+
for ((lineno = fn_start; lineno <= fn_end; lineno++)); do
483+
local line_content
484+
line_content=$(sed -n "${lineno}p" "$file" 2>/dev/null) || continue
485+
486+
if bashunit::coverage::is_executable_line "$line_content" "$lineno"; then
487+
((executable++))
488+
local line_hits=${_hits_ref[$lineno]:-0}
489+
if [[ $line_hits -gt 0 ]]; then
490+
((hit++))
491+
fi
492+
fi
493+
done
494+
495+
local pct=0
496+
if [[ $executable -gt 0 ]]; then
497+
pct=$((hit * 100 / executable))
498+
fi
499+
500+
echo "${hit}:${executable}:${pct}"
501+
}
502+
397503
function bashunit::coverage::get_percentage() {
398504
local total_executable=0
399505
local total_hit=0
@@ -692,6 +798,25 @@ function bashunit::coverage::generate_index_html() {
692798
# Calculate gauge stroke offset (440 is full circle circumference)
693799
local gauge_offset=$((440 - (440 * total_pct / 100)))
694800

801+
# Determine coverage level and colors for gauge
802+
local gauge_color_start gauge_color_end gauge_text_gradient
803+
if [[ $total_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80} ]]; then
804+
# High coverage - green
805+
gauge_color_start="#10b981"
806+
gauge_color_end="#34d399"
807+
gauge_text_gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)"
808+
elif [[ $total_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50} ]]; then
809+
# Medium coverage - yellow/orange
810+
gauge_color_start="#f59e0b"
811+
gauge_color_end="#fbbf24"
812+
gauge_text_gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)"
813+
else
814+
# Low coverage - red
815+
gauge_color_start="#ef4444"
816+
gauge_color_end="#f87171"
817+
gauge_text_gradient="linear-gradient(135deg, #ef4444 0%, #f87171 100%)"
818+
fi
819+
695820
{
696821
cat << 'EOF'
697822
<!DOCTYPE html>
@@ -730,7 +855,9 @@ function bashunit::coverage::generate_index_html() {
730855
@keyframes gaugeAnimation { from { stroke-dashoffset: 440; } }
731856
@keyframes fadeInUp { from { opacity: 0; } to { opacity: 1 } }
732857
.gauge-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; width: 100%; }
733-
.gauge-percent { font-size: 3.5rem; font-weight: 800; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1; margin: 0; display: block; }
858+
EOF
859+
echo " .gauge-percent { font-size: 3.5rem; font-weight: 800; background: ${gauge_text_gradient}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1; margin: 0; display: block; }"
860+
cat << 'EOF'
734861
.gauge-label { color: var(--text-secondary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 2px; margin: 0; display: block; }
735862
.gauge-info { flex: 1; }
736863
.gauge-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 12px; }
@@ -832,8 +959,10 @@ EOF
832959
<svg viewBox="0 0 160 160" width="200" height="200">
833960
<defs>
834961
<linearGradient id="gaugeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
835-
<stop offset="0%" style="stop-color:#667eea"/>
836-
<stop offset="100%" style="stop-color:#764ba2"/>
962+
EOF
963+
echo " <stop offset=\"0%\" style=\"stop-color:${gauge_color_start}\"/>"
964+
echo " <stop offset=\"100%\" style=\"stop-color:${gauge_color_end}\"/>"
965+
cat << 'EOF'
837966
</linearGradient>
838967
</defs>
839968
<circle class="gauge-bg" cx="80" cy="80" r="70"/>
@@ -1153,6 +1282,43 @@ EOF
11531282
.uncovered .line-num, .uncovered .hits { background: #fecaca; border-color: var(--danger-border); }
11541283
.uncovered:hover { background: #fef2f2; }
11551284
.uncovered:hover .line-num, .uncovered:hover .hits { background: #fee2e2; }
1285+
.function-summary { max-width: 1600px; margin: 30px auto; padding: 0 30px; }
1286+
.function-table { background: var(--bg-card); border-radius: 16px; overflow: hidden; border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); width: 100%; border-collapse: collapse; }
1287+
.function-table th { background: #f1f5f9; padding: 14px 20px; text-align: left; font-weight: 600; color: var(--text-secondary); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid var(--border); }
1288+
.function-table th:first-child { width: 40%; }
1289+
.function-table th:nth-child(2), .function-table th:nth-child(3) { text-align: center; }
1290+
.function-table td { padding: 12px 20px; border-bottom: 1px solid var(--border); vertical-align: middle; }
1291+
.function-table tr:last-child td { border-bottom: none; }
1292+
.function-table tbody tr { transition: background 0.15s; }
1293+
.function-table tbody tr:hover { background: var(--bg-hover); }
1294+
.function-table tbody tr.fn-covered { background: #f0fdf4; }
1295+
.function-table tbody tr.fn-covered:hover { background: #dcfce7; }
1296+
.function-table tbody tr.fn-partial { background: #fffbeb; }
1297+
.function-table tbody tr.fn-partial:hover { background: #fef3c7; }
1298+
.function-table tbody tr.fn-uncovered { background: #fef2f2; }
1299+
.function-table tbody tr.fn-uncovered:hover { background: #fee2e2; }
1300+
.fn-name { font-weight: 600; color: var(--primary); cursor: pointer; text-decoration: none; font-family: 'SF Mono', 'Consolas', 'Liberation Mono', Menlo, monospace; font-size: 0.95rem; }
1301+
.fn-name:hover { color: var(--primary-dark); text-decoration: underline; }
1302+
.fn-lines { text-align: center; color: var(--text-secondary); font-size: 0.9rem; }
1303+
.fn-coverage-cell { text-align: center; }
1304+
.fn-coverage-bar { display: flex; align-items: center; gap: 12px; justify-content: center; }
1305+
.fn-progress { width: 100px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
1306+
.fn-progress-fill { height: 100%; border-radius: 4px; }
1307+
.fn-progress-fill.high { background: var(--success); }
1308+
.fn-progress-fill.medium { background: var(--warning); }
1309+
.fn-progress-fill.low { background: var(--danger); }
1310+
.fn-pct { font-weight: 600; font-size: 0.9rem; min-width: 50px; text-align: right; }
1311+
.fn-pct.high { color: var(--success); }
1312+
.fn-pct.medium { color: var(--warning); }
1313+
.fn-pct.low { color: var(--danger); }
1314+
.line-anchor { scroll-margin-top: 200px; }
1315+
.line-anchor:target { animation: highlightFade 4s ease-out forwards; }
1316+
.line-anchor:target .line-num, .line-anchor:target .hits { animation: highlightFade 4s ease-out forwards; }
1317+
@keyframes highlightFade {
1318+
0% { background: #93c5fd; }
1319+
70% { background: #dbeafe; }
1320+
100% { background: transparent; }
1321+
}
11561322
.footer { max-width: 1600px; margin: 0 auto; padding: 40px 30px; text-align: center; }
11571323
.footer-text { color: var(--text-muted); font-size: 0.9rem; }
11581324
.footer-link { color: var(--primary-light); text-decoration: none; font-weight: 500; }
@@ -1230,6 +1396,85 @@ EOF
12301396
</div>
12311397
</div>
12321398
</div>
1399+
EOF
1400+
1401+
# Extract functions and generate summary table
1402+
local functions_data
1403+
functions_data=$(bashunit::coverage::extract_functions "$file")
1404+
1405+
if [[ -n "$functions_data" ]]; then
1406+
cat << 'EOF'
1407+
<div class="function-summary">
1408+
<table class="function-table">
1409+
<thead>
1410+
<tr>
1411+
<th>Function</th>
1412+
<th>Lines</th>
1413+
<th>Coverage</th>
1414+
</tr>
1415+
</thead>
1416+
<tbody>
1417+
EOF
1418+
local fn_entry
1419+
while IFS= read -r fn_entry; do
1420+
[[ -z "$fn_entry" ]] && continue
1421+
local fn_name fn_start fn_end
1422+
fn_name="${fn_entry%%:*}"
1423+
local rest="${fn_entry#*:}"
1424+
fn_start="${rest%%:*}"
1425+
fn_end="${rest#*:}"
1426+
1427+
# Calculate function coverage using pre-loaded hits data
1428+
local fn_executable=0
1429+
local fn_hit=0
1430+
local ln
1431+
for ((ln = fn_start; ln <= fn_end; ln++)); do
1432+
local ln_content
1433+
ln_content=$(sed -n "${ln}p" "$file" 2>/dev/null) || continue
1434+
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
1435+
((fn_executable++))
1436+
local ln_hits=${hits_by_line[$ln]:-0}
1437+
if [[ $ln_hits -gt 0 ]]; then
1438+
((fn_hit++))
1439+
fi
1440+
fi
1441+
done
1442+
1443+
local fn_pct=0
1444+
if [[ $fn_executable -gt 0 ]]; then
1445+
fn_pct=$((fn_hit * 100 / fn_executable))
1446+
fi
1447+
1448+
local fn_class="low"
1449+
local row_class="fn-uncovered"
1450+
if [[ $fn_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80} ]]; then
1451+
fn_class="high"
1452+
row_class="fn-covered"
1453+
elif [[ $fn_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50} ]]; then
1454+
fn_class="medium"
1455+
row_class="fn-partial"
1456+
fi
1457+
1458+
echo " <tr class=\"$row_class\">"
1459+
echo " <td><a href=\"#line-${fn_start}\" class=\"fn-name\">${fn_name}</a></td>"
1460+
echo " <td class=\"fn-lines\">${fn_hit} / ${fn_executable}</td>"
1461+
echo " <td class=\"fn-coverage-cell\">"
1462+
echo " <div class=\"fn-coverage-bar\">"
1463+
echo " <div class=\"fn-progress\"><div class=\"fn-progress-fill ${fn_class}\" style=\"width: ${fn_pct}%;\"></div></div>"
1464+
echo " <span class=\"fn-pct ${fn_class}\">${fn_pct}%</span>"
1465+
echo " </div>"
1466+
echo " </td>"
1467+
echo " </tr>"
1468+
done <<< "$functions_data"
1469+
1470+
cat << 'EOF'
1471+
</tbody>
1472+
</table>
1473+
</div>
1474+
EOF
1475+
fi
1476+
1477+
cat << 'EOF'
12331478
<div class="code-container">
12341479
<div class="code-wrapper">
12351480
<div class="code-header">
@@ -1283,7 +1528,7 @@ EOF
12831528
fi
12841529
fi
12851530

1286-
echo " <tr class=\"$row_class\">"
1531+
echo " <tr id=\"line-${lineno}\" class=\"$row_class line-anchor\">"
12871532
echo " <td class=\"line-num\">$lineno</td>"
12881533
echo " <td class=\"hits\">$hits_display</td>"
12891534
echo " <td class=\"code\">$escaped_line</td>"

0 commit comments

Comments
 (0)