@@ -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+
397503function 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; }
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