@@ -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
@@ -1176,6 +1282,43 @@ EOF
11761282 .uncovered .line-num, .uncovered .hits { background: #fecaca; border-color: var(--danger-border); }
11771283 .uncovered:hover { background: #fef2f2; }
11781284 .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+ }
11791322 .footer { max-width: 1600px; margin: 0 auto; padding: 40px 30px; text-align: center; }
11801323 .footer-text { color: var(--text-muted); font-size: 0.9rem; }
11811324 .footer-link { color: var(--primary-light); text-decoration: none; font-weight: 500; }
@@ -1253,6 +1396,85 @@ EOF
12531396 </div>
12541397 </div>
12551398 </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 '
12561478 <div class="code-container">
12571479 <div class="code-wrapper">
12581480 <div class="code-header">
@@ -1306,7 +1528,7 @@ EOF
13061528 fi
13071529 fi
13081530
1309- echo " <tr class=\" $row_class \" >"
1531+ echo " <tr id= \" line- ${lineno} \" class=\" $row_class line-anchor \" >"
13101532 echo " <td class=\" line-num\" >$lineno </td>"
13111533 echo " <td class=\" hits\" >$hits_display </td>"
13121534 echo " <td class=\" code\" >$escaped_line </td>"
0 commit comments