diff --git a/.claude/rules/architecture-map.md b/.claude/rules/architecture-map.md index 2a34ec90..a5b12acf 100644 --- a/.claude/rules/architecture-map.md +++ b/.claude/rules/architecture-map.md @@ -68,7 +68,16 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). | `clock.sh` | time impl selection (EPOCHREALTIME > date > perl > …), return-slot reads | | `str.sh` / `math.sh` / `io.sh` / `globals.sh` | pure-bash utilities; `globals.sh` has `temp_file`/`temp_dir` (public test API) | | `test_doubles.sh` | spy/mock state via `_BASHUNIT_SPY_*` globals + files | -| `coverage.sh` | DEBUG-trap line tracking; only active under `--coverage` | +| `coverage.sh` | aggregator only — sources the `src/coverage/` modules below | +| `coverage/config.sh` | data-file locations, tracked-file roots, engine selection (`init` resets state owned by several modules) | +| `coverage/paths.sh` | `normalize_path`, `should_track` and the hot-path track/path caches | +| `coverage/engine.sh` | DEBUG-trap and xtrace capture, buffering, `finalize`/`cleanup`, parallel merge; only active under `--coverage` | +| `coverage/lines.sh` | executable-line classification (`_NONEXEC_PATTERN`) and hit-data reading | +| `coverage/stats.sh` | percentages, the precomputed per-file stats cache, threshold gate | +| `coverage/functions.sh` | function definitions and their line spans | +| `coverage/branches.sh` | branch extraction + hit computation; **one file on purpose** — the `_branch_*` helpers mutate `extract_branches`'s locals via dynamic scoping | +| `coverage/report_text.sh` / `report_lcov.sh` / `report_html.sh` | the three renderers | +| `coverage/html_index.sh` / `html_file.sh` | HTML page emitters; the only two files exempt from `max_line_length` | | `rerun.sh` | `.bashunit/last-failed` cache for `--rerun-failed` | | `reports.sh` | JUnit/HTML/TAP/JSON writers | | `check_os.sh` / `dependencies.sh` | one-fork OS detect; `command -v` probes (builtins, not forks) | diff --git a/.editorconfig b/.editorconfig index 5bae05d6..45fce408 100644 --- a/.editorconfig +++ b/.editorconfig @@ -32,5 +32,12 @@ indent_size = unset [.claude/**.md] indent_size = unset -[src/coverage.sh] +# The two HTML page emitters are template-heavy: their long lines are single +# printf'd markup rows that reflowing would only make harder to read. Scoped to +# these two files on purpose -- every other src/coverage/ module honours the +# global 120 limit, which is a net tightening over the old whole-file exemption. +[src/coverage/html_index.sh] +max_line_length = unset + +[src/coverage/html_file.sh] max_line_length = unset diff --git a/.gitignore b/.gitignore index bd72b341..f8fdfb16 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,9 @@ log-junit.xml report.html # internal -coverage/ +# Anchored: the generated report lives at the repo root. Unanchored, this also +# matched the src/coverage/ source module and silently excluded it from git. +/coverage/ local/ tmp/ dev.log diff --git a/src/coverage.sh b/src/coverage.sh index c72bdebb..30a0bf6f 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -1,2548 +1,22 @@ #!/usr/bin/env bash -# shellcheck disable=SC2094 -# Coverage data storage -# Use :- to preserve inherited values from parent bashunit processes -_BASHUNIT_COVERAGE_DATA_FILE="${_BASHUNIT_COVERAGE_DATA_FILE:-}" -_BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" - -# Simple file-based cache for tracked files (Bash 3.0 compatible) -# The tracked cache file stores files that have already been processed -_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-}" - -# File to store which tests hit each line (for detailed coverage tooltips) -_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" - -# Engine picked once in init and inherited by every worker, so the per-test -# enable path never re-forks resolve_engine -_BASHUNIT_COVERAGE_ENGINE_RESOLVED="${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" - -# In-memory buffer for coverage data (reduces file I/O) -_BASHUNIT_COVERAGE_BUFFER="" -_BASHUNIT_COVERAGE_BUFFER_COUNT=0 -_BASHUNIT_COVERAGE_BUFFER_LIMIT=100 -_BASHUNIT_COVERAGE_HITS_BUFFER="" - -# In-memory caches for hot-path lookups (avoids grep + subshells) -_BASHUNIT_COVERAGE_TRACK_CACHE="" -_BASHUNIT_COVERAGE_PATH_CACHE="" -_BASHUNIT_COVERAGE_IS_PARALLEL="" - -# Auto-discover coverage paths from test file names -# When no explicit coverage paths are set, find source files matching test file base names -# Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh -function bashunit::coverage::auto_discover_paths() { - local project_root - project_root="$(pwd)" - local -a discovered_paths=() - local discovered_paths_count=0 - local test_file - - for test_file in "$@"; do - # Extract base name: tests/unit/assert_test.sh -> assert_test.sh - local file_basename - file_basename=$(basename "$test_file") - - # Remove test suffixes to get source name: assert_test.sh -> assert - local source_name="${file_basename%_test.sh}" - [ "$source_name" = "$file_basename" ] && source_name="${file_basename%Test.sh}" - [ "$source_name" = "$file_basename" ] && continue # Not a test file pattern - - # Find matching source files recursively - local found_file - while IFS= read -r -d '' found_file; do - # Skip test files and vendor directories - case "$found_file" in - *test* | *Test* | *vendor* | *node_modules*) continue ;; - esac - discovered_paths[discovered_paths_count]="$found_file" - discovered_paths_count=$((discovered_paths_count + 1)) - done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null) - done - - # Return unique paths, comma-separated - if [ "$discovered_paths_count" -gt 0 ]; then - printf '%s\n' "${discovered_paths[@]}" | sort -u | tr '\n' ',' | sed 's/,$//' - fi -} - -function bashunit::coverage::init() { - if ! bashunit::env::is_coverage_enabled; then - return 0 - fi - - # Skip coverage init if we're a subprocess of another coverage-enabled bashunit - # This prevents nested bashunit calls (e.g., in acceptance tests) from - # interfering with the parent's coverage tracking - if [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ]; then - export BASHUNIT_COVERAGE=false - return 0 - fi - - # Create coverage data directory with unique name via mktemp -d - # (avoids $$-$RANDOM collisions and symlink races in shared temp dirs) - local coverage_dir - coverage_dir=$("${MKTEMP:-mktemp}" -d "${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}/bashunit-coverage.XXXXXXXX") - - _BASHUNIT_COVERAGE_DATA_FILE="${coverage_dir}/hits.dat" - _BASHUNIT_COVERAGE_TRACKED_FILES="${coverage_dir}/files.dat" - _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${coverage_dir}/cache.dat" - _BASHUNIT_COVERAGE_TEST_HITS_FILE="${coverage_dir}/test_hits.dat" - - # Initialize empty files - : >"$_BASHUNIT_COVERAGE_DATA_FILE" - : >"$_BASHUNIT_COVERAGE_TRACKED_FILES" - : >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" - : >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" - - # Reset in-memory caches and buffers - _BASHUNIT_COVERAGE_BUFFER="" - _BASHUNIT_COVERAGE_BUFFER_COUNT=0 - _BASHUNIT_COVERAGE_HITS_BUFFER="" - _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="" - - _BASHUNIT_COVERAGE_ENGINE_RESOLVED=$(bashunit::coverage::resolve_engine) - - export _BASHUNIT_COVERAGE_DATA_FILE - export _BASHUNIT_COVERAGE_TRACKED_FILES - export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE - export _BASHUNIT_COVERAGE_TEST_HITS_FILE - export _BASHUNIT_COVERAGE_ENGINE_RESOLVED -} - -# Field separator inside an xtrace PS4 prefix. A control character keeps the -# parse correct for paths containing spaces or colons. -_BASHUNIT_COVERAGE_XTRACE_FS=$'\034' -# Bash replicates only PS4's *first* character once per nesting level, so the -# literal '|' that follows it always marks where the path begins, whatever the -# trace depth. -_BASHUNIT_COVERAGE_XTRACE_PS4='@|${BASH_SOURCE}'"$_BASHUNIT_COVERAGE_XTRACE_FS"'${LINENO}'"$_BASHUNIT_COVERAGE_XTRACE_FS"' ' - -# Per-shell xtrace state. Empty FD means the xtrace engine is not active, which -# is what disable_trap dispatches on. -_BASHUNIT_COVERAGE_XTRACE_FD="" -_BASHUNIT_COVERAGE_XTRACE_FILE="" -_BASHUNIT_COVERAGE_XTRACE_SAVED_PS4="" - -# Return slots for _resolve_output_files -_BASHUNIT_COVERAGE_DATA_TARGET_OUT="" -_BASHUNIT_COVERAGE_HITS_TARGET_OUT="" - -## -# Whether this Bash can send xtrace to a private file descriptor. -# BASH_XTRACEFD and the {fd}> auto-allocation it relies on both landed in 4.1; -# below that, xtrace can only reach stderr, where it would intermix with the -# output of the code under test. -# Returns: 0 when supported, 1 otherwise -## -function bashunit::coverage::xtrace_is_supported() { - if [ "${BASH_VERSINFO[0]}" -gt 4 ]; then - return 0 - fi - [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 1 ] -} - -## -# Resolve BASHUNIT_COVERAGE_ENGINE (auto|xtrace|trap) to the engine to run. -# Anything unrecognised, and any xtrace request this Bash cannot honour, falls -# back to the trap engine: it is slower but available everywhere. -# Returns: prints "xtrace" or "trap" -## -function bashunit::coverage::resolve_engine() { - case "${BASHUNIT_COVERAGE_ENGINE:-auto}" in - xtrace | auto) - if bashunit::coverage::xtrace_is_supported; then - echo "xtrace" - else - echo "trap" - fi - ;; - *) echo "trap" ;; - esac -} - -# Name kept from the trap-only era: this is the seam runner/exec.sh and -# runner/hooks.sh already call around every test body and lifecycle hook. -function bashunit::coverage::enable_trap() { - if ! bashunit::env::is_coverage_enabled; then - return 0 - fi - - local engine="${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" - if [ -z "$engine" ]; then - engine=$(bashunit::coverage::resolve_engine) - fi - - if [ "$engine" = "xtrace" ]; then - bashunit::coverage::_enable_xtrace - return 0 - fi - - # Enable trap inheritance into functions - set -T - - # Set DEBUG trap to record line execution - # Use ${VAR:-} to handle unset variables when set -u is active (in subshells) - # shellcheck disable=SC2154 - trap 'bashunit::coverage::record_line "${BASH_SOURCE[0]:-}" "${LINENO:-}"' DEBUG -} - -function bashunit::coverage::disable_trap() { - if [ -n "$_BASHUNIT_COVERAGE_XTRACE_FD" ]; then - bashunit::coverage::_disable_xtrace - return 0 - fi - - trap - DEBUG - set +T - # Flush any remaining buffered coverage data - bashunit::coverage::flush_buffer -} - -# Point xtrace at a per-worker trace file and mark the test boundary. -# -# The trace is *appended to and never parsed here*: parsing per test costs an -# awk fork plus a full pass per test, which measured ~27x the cost of capturing -# and cancelled the engine's whole reason to exist. Attribution that the trap -# engine reads from globals is written into the trace as a sentinel instead, and -# bashunit::coverage::finalize does one pass over everything after the run. -# -# Concurrent workers only ever run within a single test file, so the dispatcher -# ordinal that already names their .result files also keeps traces apart — -# BASHPID is Bash 4.0+ and banned by the compatibility gate. -function bashunit::coverage::_enable_xtrace() { - local coverage_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" - _BASHUNIT_COVERAGE_XTRACE_FILE="${coverage_dir}/xtrace.$$.${_BASHUNIT_RUNNER_RESULT_ORDINAL:-0}.trace" - - exec {_BASHUNIT_COVERAGE_XTRACE_FD}>>"$_BASHUNIT_COVERAGE_XTRACE_FILE" - - local test_ctx="" - if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && - [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then - test_ctx="${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" - fi - builtin printf '%sTEST%s%s\n' "$_BASHUNIT_COVERAGE_XTRACE_FS" \ - "$_BASHUNIT_COVERAGE_XTRACE_FS" "$test_ctx" >&"$_BASHUNIT_COVERAGE_XTRACE_FD" - - _BASHUNIT_COVERAGE_XTRACE_SAVED_PS4="${PS4:-}" - BASH_XTRACEFD=$_BASHUNIT_COVERAGE_XTRACE_FD - PS4=$_BASHUNIT_COVERAGE_XTRACE_PS4 - set -x -} - -# Stop tracing and undo anything the test body may have changed. Restoring PS4 -# is what keeps a test that sets its own xtrace from corrupting later traces. -function bashunit::coverage::_disable_xtrace() { - set +x - PS4=$_BASHUNIT_COVERAGE_XTRACE_SAVED_PS4 - - local fd="$_BASHUNIT_COVERAGE_XTRACE_FD" - _BASHUNIT_COVERAGE_XTRACE_FD="" - _BASHUNIT_COVERAGE_XTRACE_FILE="" - - unset BASH_XTRACEFD - exec {fd}>&- -} - -# Collect the distinct source paths a trace mentions, so the shell can make the -# should_track decision awk cannot. -_BASHUNIT_COVERAGE_XTRACE_PATHS_AWK=' -{ - if (substr($0, 1, 1) != "@") { next } - i = 1 - while (substr($0, i, 1) == "@") { i++ } - if (substr($0, i, 1) != "|") { next } - rest = substr($0, i + 1) - p = index(rest, fsc) - if (p == 0) { next } - path = substr(rest, 1, p - 1) - if (path != "" && !(path in seen)) { seen[path] = 1; print path } -} -' - -# Emit hit records, attributing each to the test named by the last sentinel. -_BASHUNIT_COVERAGE_XTRACE_EMIT_AWK=' -FILENAME == map_file { - tab = index($0, "\t") - if (tab > 0) { - norm = substr($0, tab + 1) - if (norm != "-") { tracked[substr($0, 1, tab - 1)] = norm } - } - next -} -substr($0, 1, 1) == fsc { - head = substr($0, 2) - if (substr(head, 1, 5) == "TEST" fsc) { ctx = substr(head, 6); next } -} -{ - if (substr($0, 1, 1) != "@") { next } - i = 1 - while (substr($0, i, 1) == "@") { i++ } - if (substr($0, i, 1) != "|") { next } - rest = substr($0, i + 1) - p = index(rest, fsc) - if (p == 0) { next } - path = substr(rest, 1, p - 1) - if (!(path in tracked)) { next } - tail = substr(rest, p + 1) - q = index(tail, fsc) - if (q == 0) { next } - line = substr(tail, 1, q - 1) - if (line == "") { next } - record = tracked[path] ":" line - print record >> data_out - if (ctx != "") { print record "|" ctx >> hits_out } -} -' - -## -# Fold every captured xtrace into the hit records the reports read. -# No-op unless the xtrace engine ran. Must be called after the last test and -# before aggregate_parallel, from the main shell. -## -function bashunit::coverage::finalize() { - if [ "${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" != "xtrace" ]; then - return 0 - fi - [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] || return 0 - - local coverage_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" - local -a traces=() - local trace - for trace in "$coverage_dir"/xtrace.*.trace; do - [ -f "$trace" ] || continue - traces[${#traces[@]}]="$trace" - done - [ "${#traces[@]}" -gt 0 ] || return 0 - - local map_file="${coverage_dir}/xtrace-map.dat" - local paths_file="${coverage_dir}/xtrace-paths.dat" - : >"$map_file" - - "$AWK" -v fsc="$_BASHUNIT_COVERAGE_XTRACE_FS" \ - "$_BASHUNIT_COVERAGE_XTRACE_PATHS_AWK" "${traces[@]}" >"$paths_file" - - local path - while IFS= read -r path; do - [ -n "$path" ] || continue - if bashunit::coverage::should_track "$path"; then - builtin printf '%s\t%s\n' "$path" \ - "$(bashunit::coverage::normalize_path "$path")" >>"$map_file" - else - builtin printf '%s\t-\n' "$path" >>"$map_file" - fi - done <"$paths_file" - - "$AWK" \ - -v map_file="$map_file" \ - -v fsc="$_BASHUNIT_COVERAGE_XTRACE_FS" \ - -v data_out="$_BASHUNIT_COVERAGE_DATA_FILE" \ - -v hits_out="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" \ - "$_BASHUNIT_COVERAGE_XTRACE_EMIT_AWK" "$map_file" "${traces[@]}" - - rm -f "$paths_file" "${traces[@]}" -} - -# Normalize file path to absolute -function bashunit::coverage::normalize_path() { - local file="$1" - - # Normalize path to absolute - if [ -f "$file" ]; then - echo "$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" - else - echo "$file" - fi -} - -# Get deduplicated list of tracked files -function bashunit::coverage::get_tracked_files() { - if [ ! -f "$_BASHUNIT_COVERAGE_TRACKED_FILES" ]; then - return - fi - sort -u "$_BASHUNIT_COVERAGE_TRACKED_FILES" -} - -# Get coverage class (high/medium/low) based on percentage -function bashunit::coverage::get_coverage_class() { - local pct="$1" - if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then - echo "high" - elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then - echo "medium" - else - echo "low" - fi -} - -function bashunit::coverage::get_color_for_class() { - case "$1" in - high) printf '%s' "$_BASHUNIT_COLOR_PASSED" ;; - medium) printf '%s' "$_BASHUNIT_COLOR_SKIPPED" ;; - low) printf '%s' "$_BASHUNIT_COLOR_FAILED" ;; - esac -} - -# Calculate percentage from hit and executable counts -function bashunit::coverage::calculate_percentage() { - local hit="$1" - local executable="$2" - if [ "$executable" -gt 0 ]; then - echo $((hit * 100 / executable)) - else - echo "0" - fi -} - -# Get file coverage stats as "executable:hit:pct:class" -function bashunit::coverage::get_file_stats() { - local file="$1" - 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") - 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" - - # Skip if no file or line - { [ -z "$file" ] || [ -z "$lineno" ]; } && return 0 - - # Skip if coverage data file doesn't exist (trap inherited by child process) - [ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ] && return 0 - - # Fast in-memory should_track cache (avoids grep + file I/O per line) - case "$_BASHUNIT_COVERAGE_TRACK_CACHE" in - *"|${file}:0|"*) return 0 ;; - *"|${file}:1|"*) ;; - *) - # Not cached yet — run full check and cache result - if bashunit::coverage::should_track "$file"; then - _BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:1|" - else - _BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:0|" - return 0 - fi - ;; - esac - - # Fast in-memory path normalization cache (avoids cd + pwd subshell per line) - local normalized_file="" - case "$_BASHUNIT_COVERAGE_PATH_CACHE" in - *"|${file}="*) - # Extract cached value - normalized_file="${_BASHUNIT_COVERAGE_PATH_CACHE#*"|${file}="}" - normalized_file="${normalized_file%%"|"*}" - ;; - *) - normalized_file=$(bashunit::coverage::normalize_path "$file") - _BASHUNIT_COVERAGE_PATH_CACHE="${_BASHUNIT_COVERAGE_PATH_CACHE}|${file}=${normalized_file}|" - ;; - esac - - # Buffer the coverage data in memory - _BASHUNIT_COVERAGE_BUFFER="${_BASHUNIT_COVERAGE_BUFFER}${normalized_file}:${lineno} -" - # Also buffer test hit data if in a test context - if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && - [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then - _BASHUNIT_COVERAGE_HITS_BUFFER="${_BASHUNIT_COVERAGE_HITS_BUFFER}${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN} -" - fi - - _BASHUNIT_COVERAGE_BUFFER_COUNT=$((_BASHUNIT_COVERAGE_BUFFER_COUNT + 1)) - - # Flush buffer to disk when threshold is reached - if [ "$_BASHUNIT_COVERAGE_BUFFER_COUNT" -ge \ - "$_BASHUNIT_COVERAGE_BUFFER_LIMIT" ]; then - bashunit::coverage::flush_buffer - fi -} - -# Resolve the parallel-safe destinations for hit records into the return slots -# _BASHUNIT_COVERAGE_DATA_TARGET_OUT and _BASHUNIT_COVERAGE_HITS_TARGET_OUT. -function bashunit::coverage::_resolve_output_files() { - # Cache the parallel check to avoid function calls - if [ -z "$_BASHUNIT_COVERAGE_IS_PARALLEL" ]; then - if bashunit::parallel::is_enabled; then - _BASHUNIT_COVERAGE_IS_PARALLEL="yes" - else - _BASHUNIT_COVERAGE_IS_PARALLEL="no" - fi - fi - - if [ "$_BASHUNIT_COVERAGE_IS_PARALLEL" = "yes" ]; then - _BASHUNIT_COVERAGE_DATA_TARGET_OUT="${_BASHUNIT_COVERAGE_DATA_FILE}.$$" - _BASHUNIT_COVERAGE_HITS_TARGET_OUT="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$" - else - _BASHUNIT_COVERAGE_DATA_TARGET_OUT="$_BASHUNIT_COVERAGE_DATA_FILE" - _BASHUNIT_COVERAGE_HITS_TARGET_OUT="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" - fi -} - -function bashunit::coverage::flush_buffer() { - [ -z "$_BASHUNIT_COVERAGE_BUFFER" ] && return 0 - - bashunit::coverage::_resolve_output_files - local data_file="$_BASHUNIT_COVERAGE_DATA_TARGET_OUT" - local test_hits_file="$_BASHUNIT_COVERAGE_HITS_TARGET_OUT" - - # Write buffered data in a single I/O operation. - # Use `builtin printf` so a user test spying/mocking the printf builtin - # cannot shadow the coverage write and silently drop data (see issue #724). - builtin printf '%s' "$_BASHUNIT_COVERAGE_BUFFER" >>"$data_file" - - if [ -n "$_BASHUNIT_COVERAGE_HITS_BUFFER" ]; then - builtin printf '%s' "$_BASHUNIT_COVERAGE_HITS_BUFFER" >>"$test_hits_file" - fi - - # Reset buffer - _BASHUNIT_COVERAGE_BUFFER="" - _BASHUNIT_COVERAGE_HITS_BUFFER="" - _BASHUNIT_COVERAGE_BUFFER_COUNT=0 -} - -function bashunit::coverage::should_track() { - local file="$1" - - # Skip empty paths - [ -z "$file" ] && return 1 - - # Skip if tracked files list doesn't exist (trap inherited by child process) - [ -z "$_BASHUNIT_COVERAGE_TRACKED_FILES" ] && return 1 - - # Check file-based cache for previous decision (Bash 3.0 compatible) - # Cache format: "file:0" for excluded, "file:1" for tracked - # In parallel mode, use per-process cache to avoid race conditions - local cache_file="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" - if bashunit::parallel::is_enabled && [ -n "$cache_file" ]; then - cache_file="${cache_file}.$$" - # Initialize per-process cache if needed - [ ! -f "$cache_file" ] && [ -d "$(dirname "$cache_file")" ] && : >"$cache_file" - fi - if [ -n "$cache_file" ] && [ -f "$cache_file" ]; then - local cached_decision - # Use || true to prevent exit in strict mode when grep finds no match - cached_decision=$(grep "^${file}:" "$cache_file" 2>/dev/null | head -1) || true - if [ -n "$cached_decision" ]; then - [ "${cached_decision##*:}" = "1" ] && return 0 || return 1 - fi - fi - - # Normalize path - local normalized_file - normalized_file=$(bashunit::coverage::normalize_path "$file") - - # Check exclusion patterns - # Save and restore IFS to avoid corrupting caller's environment - local old_ifs="$IFS" - IFS=',' - local pattern - for pattern in $BASHUNIT_COVERAGE_EXCLUDE; do - # shellcheck disable=SC2254 - case "$normalized_file" in - *$pattern*) - IFS="$old_ifs" - # Cache exclusion decision (use per-process cache in parallel mode) - { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:0" >>"$cache_file" - return 1 - ;; - esac - done - - # Check inclusion paths - local matched=false - local path - for path in $BASHUNIT_COVERAGE_PATHS; do - # Resolve relative paths - local resolved_path - case "$path" in - /*) - resolved_path="$path" - ;; - *) - resolved_path="$(pwd)/$path" - ;; - esac - - case "$normalized_file" in - "$resolved_path"*) - matched=true - break - ;; - esac - done - IFS="$old_ifs" - - if [ "$matched" = "false" ]; then - # Cache exclusion decision (use per-process cache in parallel mode) - { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:0" >>"$cache_file" - return 1 - fi - - # Cache tracking decision (use per-process cache in parallel mode) - { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:1" >>"$cache_file" - - # Track this file for later reporting - # In parallel mode, use a per-process file to avoid race conditions - local tracked_file="$_BASHUNIT_COVERAGE_TRACKED_FILES" - if bashunit::parallel::is_enabled; then - tracked_file="${_BASHUNIT_COVERAGE_TRACKED_FILES}.$$" - fi - - # Only write if parent directory exists - if [ -d "$(dirname "$tracked_file")" ]; then - # Check if not already written to avoid duplicates - if ! grep -q "^${normalized_file}$" "$tracked_file" 2>/dev/null; then - echo "$normalized_file" >>"$tracked_file" - fi - fi - - return 0 -} - -function bashunit::coverage::aggregate_parallel() { - # Aggregate per-process coverage files created during parallel execution - local base_file="$_BASHUNIT_COVERAGE_DATA_FILE" - local tracked_base="$_BASHUNIT_COVERAGE_TRACKED_FILES" - local test_hits_base="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" - - # Find and merge all per-process coverage data files - # Use nullglob to handle case when no files match - local pid_files pid_file - pid_files=$(ls -1 "${base_file}."* 2>/dev/null) || true - if [ -n "$pid_files" ]; then - while IFS= read -r pid_file; do - [ -f "$pid_file" ] || continue - cat "$pid_file" >>"$base_file" - rm -f "$pid_file" - done <<<"$pid_files" - fi - - # Find and merge all per-process tracked files lists - pid_files=$(ls -1 "${tracked_base}."* 2>/dev/null) || true - if [ -n "$pid_files" ]; then - while IFS= read -r pid_file; do - [ -f "$pid_file" ] || continue - cat "$pid_file" >>"$tracked_base" - rm -f "$pid_file" - done <<<"$pid_files" - fi - - # Find and merge all per-process test hits files - if [ -n "$test_hits_base" ]; then - pid_files=$(ls -1 "${test_hits_base}."* 2>/dev/null) || true - if [ -n "$pid_files" ]; then - while IFS= read -r pid_file; do - [ -f "$pid_file" ] || continue - cat "$pid_file" >>"$test_hits_base" - rm -f "$pid_file" - done <<<"$pid_files" - fi - fi - - # Deduplicate tracked files - if [ -f "$tracked_base" ]; then - sort -u "$tracked_base" -o "$tracked_base" - fi -} - -# Pre-compiled combined regex of all non-executable line patterns. -# Collapses multiple grep subshells into a single invocation per line for performance. -# Each alternation is fully self-anchored so semantics match the original per-pattern checks. -# Patterns covered (in order): -# - comment-only lines (including shebang) -# - function declarations (but not single-line functions with a body) -# - brace-only lines -# - control flow keywords (then, else, fi, do, done, esac, in, ;;, ;;&, ;&) -# - loop terminators with redirection/pipe/fd (e.g. "done < file", "done | sort") -# - case patterns like "--option)" or "*) # comment" -# - standalone ) for arrays/subshells -_BASHUNIT_COVERAGE_NONEXEC_PATTERN='^[[:space:]]*#' -_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*(function[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_:]*[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*$' -_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*[\{\}][[:space:]]*$' -_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$' -_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*done[[:space:]]+[^[:space:]#].*$' -_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*[^\)]+\)[[:space:]]*(#.*)?$' -_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*\)[[:space:]]*(#.*)?$' - -# Check if a line is executable (used by get_executable_lines and report_lcov) -# Arguments: line content, line number -# Returns: 0 if executable, 1 if not -function bashunit::coverage::is_executable_line() { - local line="$1" - local lineno="$2" - - # Unused but kept for API compatibility - : "$lineno" - - # Skip empty lines (line with only whitespace) — built-in, no subshell - [ -z "${line// /}" ] && 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 -} - -function bashunit::coverage::get_executable_lines() { - local file="$1" - local count=0 - local lineno=0 - local line - - while IFS= read -r line || [ -n "$line" ]; do - ((++lineno)) - bashunit::coverage::is_executable_line "$line" "$lineno" && ((++count)) - done <"$file" - - echo "$count" -} - -function bashunit::coverage::get_hit_lines() { - local file="$1" - - if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then - echo "0" - return - fi - - # Get unique hit line numbers - local hit_lines - hit_lines=$( (grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null || true) | - cut -d: -f2 | sort -u) - - if [ -z "$hit_lines" ]; then - echo "0" - return - fi - - # 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="${file_lines[$((line_num - 1))]:-}" - [ -z "$line_content" ] && continue - if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then - ((++count)) - fi - done - - echo "$count" -} - -# Compute executable + hit counts for a file in a single source-file pass. -# Reuses get_all_line_hits to avoid scanning the coverage data per line. -# Output format: "executable:hit" -function bashunit::coverage::compute_file_coverage() { - local file="$1" - - bashunit::coverage::load_hits_by_line "$file" - - local executable=0 hit=0 lineno=0 line line_hits - 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)) - line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0} - [ "$line_hits" -gt 0 ] && ((++hit)) - done - - echo "${executable}:${hit}" -} - -# Detect whether a source line ends with a Bash line-continuation, i.e. an -# odd number of unescaped trailing backslashes with no trailing whitespace. -# Comment lines never continue. Used to propagate coverage hits from a -# statement's starting line to its continuation lines (see #722). -function bashunit::coverage::_ends_with_continuation() { - local line="$1" - local lead="${line#"${line%%[![:space:]]*}"}" - case "$lead" in '#'*) return 1 ;; esac - local trailing="${line##*[!\\]}" - case "$line" in *[!\\]*) : ;; *) trailing="$line" ;; esac - [ $((${#trailing} % 2)) -eq 1 ] -} - -# Get all line hits for a file in one pass (performance optimization) -# Output format: one "lineno:count" per line -# -# Bash's DEBUG trap attributes a multi-line statement's execution to the line -# where the statement starts; backslash continuation lines never receive their -# own hit. To match the report's expectation that continuation lines are -# covered, the start line's count is propagated forward across the -# continuation chain (see #722). -function bashunit::coverage::get_all_line_hits() { - local file="$1" - - if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then - return - fi - - # Extract all lines for this file, count occurrences of each line number. - local -a counts=() - local count lineno maxln=0 - while read -r count lineno; do - if [ -n "$lineno" ]; then - counts[lineno]=$count - [ "$lineno" -gt "$maxln" ] && maxln=$lineno - fi - done < <(grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | - cut -d: -f2 | sort | uniq -c) - - if [ "$maxln" -eq 0 ]; then - return - fi - - # Read the source so continuation lines can be detected. - local -a src=() - local _i=0 _l - while IFS= read -r _l || [ -n "$_l" ]; do - src[_i]="$_l" - ((++_i)) - done <"$file" - - local total=$_i - [ "$maxln" -gt "$total" ] && total=$maxln - - # Propagate each start line's count forward across its continuation chain. - local carry=0 idx h - for ((idx = 1; idx <= total; idx++)); do - h=${counts[idx]:-0} - if [ "$carry" -gt 0 ] && [ "$h" -lt "$carry" ]; then - h=$carry - counts[idx]=$h - fi - if [ "$h" -gt 0 ] && bashunit::coverage::_ends_with_continuation "${src[idx - 1]:-}"; then - carry=$h - else - carry=0 - fi - done - - local ln - for ((ln = 1; ln <= total; ln++)); do - [ "${counts[ln]:-0}" -gt 0 ] && echo "${ln}:${counts[ln]}" - done - - # The for loop's exit status leaks the last `[ -gt ]` test, which is 1 when the - # final line has no hits; return 0 explicitly so callers under `set -e` (strict - # mode) don't treat a successful run as a failure (see #722). - return 0 -} - -# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by -# line number -> hit count) from get_all_line_hits for $1. +# Aggregator for the src/coverage/ module: only `source` lines and comments +# belong here. build.sh emits a file's body before recursing into its `source` +# lines, so any statement here would run before its dependencies in the built +# binary (adrs/adr-010-src-module-directories.md). # -# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)" -# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an -# array from a function or pass one by reference, and copying a sparse array -# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would -# corrupt the line-number keys -- so this loader writes one shared global -# instead (return-slot pattern, see bash-style.md). Callers must consume the -# result before the next call; there is no adjacent-call isolation. -declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE -function bashunit::coverage::load_hits_by_line() { - local file="$1" - _BASHUNIT_COVERAGE_HITS_BY_LINE=() - local hl_lineno hl_count - while IFS=: read -r hl_lineno hl_count; do - [ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count - done < <(bashunit::coverage::get_all_line_hits "$file") -} - -# Get all test hits for a file in one pass (performance optimization) -# Output format: lineno|test_file:test_function (may have duplicates, one per hit) -function bashunit::coverage::get_all_line_tests() { - local file="$1" - - if [ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]; then - return - fi - - # Format in file: source_file:line|test_file:test_function - # Output: lineno|test_file:test_function - grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | - sed "s|^${file}:||" | sort -u -} - -# Extract function definitions from a bash file -# Output format: function_name:start_line:end_line (one per function) -function bashunit::coverage::extract_functions() { - local file="$1" - - local lineno=0 - local in_function=0 - local brace_count=0 - local current_fn="" - local fn_start=0 - local line - - while IFS= read -r line || [ -n "$line" ]; do - ((++lineno)) - - # Check for function definition patterns - # Pattern 1: function name() { or function name { - # Pattern 2: name() { or name () { - if [ "$in_function" -eq 0 ]; then - local fn_name="" - - # 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 - in_function=1 - current_fn="$fn_name" - fn_start=$lineno - brace_count=0 - - # Count opening braces on this line - local open_braces="${line//[^\{]/}" - local close_braces="${line//[^\}]/}" - local open_count=${#open_braces} - local close_count=${#close_braces} - brace_count=$((brace_count + open_count - close_count)) - - # 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}" - in_function=0 - current_fn="" - fi - continue - fi - fi - - # Track braces inside function - if [ "$in_function" -eq 1 ]; then - local open_braces="${line//[^\{]/}" - local close_braces="${line//[^\}]/}" - brace_count=$((brace_count + ${#open_braces} - ${#close_braces})) - - # Function ended - if [ "$brace_count" -le 0 ]; then - echo "${current_fn}|${fn_start}|${lineno}" - in_function=0 - current_fn="" - brace_count=0 - fi - fi - done <"$file" - - # Handle unclosed function (shouldn't happen in valid code) - if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then - echo "${current_fn}|${fn_start}|${lineno}" - fi -} - -# Append "start:end" to a comma-separated arms string. Result is -# returned via the global _BASHUNIT_BRANCH_ARMS_OUT to avoid the cost -# of a subshell on a hot per-line path. Bash 3.0 cannot pass arrays -# (or namerefs) by reference, so a single output slot is the cheapest -# portable option. -_BASHUNIT_BRANCH_ARMS_OUT="" -function bashunit::coverage::_append_arm() { - local existing="$1" arm_start="$2" arm_end="$3" - if [ -z "$existing" ]; then - _BASHUNIT_BRANCH_ARMS_OUT="${arm_start}:${arm_end}" - else - _BASHUNIT_BRANCH_ARMS_OUT="${existing},${arm_start}:${arm_end}" - fi -} - -# Detect whether a trimmed line is a case-pattern opener (ends with -# `)` optionally followed by whitespace and a comment). Avoids -# matching mid-line uses such as `cmd $(other)`. -function bashunit::coverage::_is_case_pattern_line() { - local trimmed="$1" - case "$trimmed" in - *')'*) ;; - *) return 1 ;; - esac - - local before_paren="${trimmed%%')'*}" - local after="${trimmed#"$before_paren"}" - after="${after#)}" - after="${after#"${after%%[![:space:]]*}"}" - case "$after" in - '' | '#'*) return 0 ;; - esac - return 1 -} - -# Extract branch points from a Bash file. -# Output format: ||:[,:]... -# kind ∈ {if, case, loop} -# Scope: if/elif/else chains, case patterns and loop bodies. -# See adrs/adr-007-branch-coverage-mvp.md. -# The handlers below operate on the per-construct state arrays that -# extract_branches keeps as locals. Bash 3.0 has dynamic scoping for -# `local` vars, so the helpers see and mutate the caller's state -# without needing namerefs (which would require Bash 4.3+). - -function bashunit::coverage::_branch_push_if() { - local lineno=$1 - if_decision_line[if_depth]=$lineno - if_arms[if_depth]="" - if_arm_start[if_depth]=$((lineno + 1)) - if_depth=$((if_depth + 1)) -} - -function bashunit::coverage::_branch_close_if_arm() { - local lineno=$1 idx=$((if_depth - 1)) - bashunit::coverage::_append_arm \ - "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" - if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" - if_arm_start[idx]=$((lineno + 1)) -} - -function bashunit::coverage::_branch_emit_if() { - local lineno=$1 idx=$((if_depth - 1)) - bashunit::coverage::_append_arm \ - "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" - echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" - if_depth=$idx -} - -function bashunit::coverage::_branch_push_case() { - local lineno=$1 - case_decision_line[case_depth]=$lineno - case_arms[case_depth]="" - case_arm_start[case_depth]=0 - case_in_pattern[case_depth]=0 - case_depth=$((case_depth + 1)) -} - -function bashunit::coverage::_branch_close_case_arm() { - local lineno=$1 idx=$((case_depth - 1)) - [ "${case_in_pattern[$idx]}" = "1" ] || return 0 - bashunit::coverage::_append_arm \ - "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$((lineno - 1))" - case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" - case_in_pattern[idx]=0 -} - -function bashunit::coverage::_branch_emit_case() { - local lineno=$1 idx=$((case_depth - 1)) - bashunit::coverage::_branch_close_case_arm "$lineno" - if [ -n "${case_arms[$idx]}" ]; then - echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" - fi - case_depth=$idx -} - -function bashunit::coverage::_branch_open_case_pattern() { - local lineno=$1 idx=$((case_depth - 1)) - case_arm_start[idx]=$((lineno + 1)) - case_in_pattern[idx]=1 -} - -# A loop (while/until/for/select) is a single-arm branch: its body. The arm is -# taken iff the loop ran at least once (an executable body line was hit); a -# never-taken body is an uncovered zero-iteration branch. Every `done`-closed -# construct must push so `done` pairs with the right opener when nested. -function bashunit::coverage::_branch_push_loop() { - local lineno=$1 - loop_decision_line[loop_depth]=$lineno - loop_arm_start[loop_depth]=$((lineno + 1)) - loop_depth=$((loop_depth + 1)) -} - -function bashunit::coverage::_branch_emit_loop() { - local lineno=$1 idx=$((loop_depth - 1)) - echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))" - loop_depth=$idx -} - -function bashunit::coverage::extract_branches() { - local file="$1" - - local -a lines=() - local _i=0 _l - while IFS= read -r _l || [ -n "$_l" ]; do - lines[_i]="$_l" - ((++_i)) - done <"$file" - local total_lines=$_i - - # State arrays — read and mutated by the _branch_* helpers via Bash's - # dynamic scoping. Each array is keyed by depth so nested constructs - # work without associative arrays. - local -a if_decision_line=() if_arms=() if_arm_start=() - local if_depth=0 - local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() - local case_depth=0 - local -a loop_decision_line=() loop_arm_start=() - local loop_depth=0 - - local lineno=0 line trimmed first - while [ "$lineno" -lt "$total_lines" ]; do - line="${lines[$lineno]}" - lineno=$((lineno + 1)) - - trimmed="${line#"${line%%[![:space:]]*}"}" - case "$trimmed" in '' | '#'*) continue ;; esac - first="${trimmed%%[[:space:]\;]*}" - - # Reserved-word patterns single-quoted to dodge `case ... esac` - # parser confusion. - case "$first" in - 'if') bashunit::coverage::_branch_push_if "$lineno" ;; - 'elif' | 'else') - [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_close_if_arm "$lineno" - ;; - 'fi') - [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_emit_if "$lineno" - ;; - 'case') bashunit::coverage::_branch_push_case "$lineno" ;; - 'esac') - [ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno" - ;; - 'while' | 'until' | 'for' | 'select') - bashunit::coverage::_branch_push_loop "$lineno" - ;; - 'done') - [ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno" - ;; - *) - [ "$case_depth" -eq 0 ] && continue - case "$trimmed" in - ';;&'* | ';;'* | ';&'*) - bashunit::coverage::_branch_close_case_arm "$lineno" - ;; - *) - if bashunit::coverage::_is_case_pattern_line "$trimmed"; then - bashunit::coverage::_branch_open_case_pattern "$lineno" - fi - ;; - esac - ;; - esac - done -} - -# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in -# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from -# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see -# load_hits_by_line); caller must have populated the src_lines array in -# scope -- Bash 3.0 cannot pass arrays into a function. Result is -# returned via the global to avoid a per-arm subshell. -_BASHUNIT_ARM_TAKEN_OUT=0 -function bashunit::coverage::_arm_taken() { - local arm_start="$1" arm_end="$2" ln - for ((ln = arm_start; ln <= arm_end; ln++)); do - bashunit::coverage::is_executable_line \ - "${src_lines[$((ln - 1))]:-}" "$ln" || continue - if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then - _BASHUNIT_ARM_TAKEN_OUT=1 - return - fi - done - _BASHUNIT_ARM_TAKEN_OUT=0 -} - -# Compute branch hit data for a file. -# Output format: ||| -# block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). -# An arm is "taken" iff at least one executable line inside its range -# has a recorded hit. taken_count is 0 or 1 — MVP does not preserve -# per-arm hit counts. -function bashunit::coverage::compute_branch_hits() { - local file="$1" - - bashunit::coverage::load_hits_by_line "$file" - - local -a src_lines=() - local _sli=0 _sl - while IFS= read -r _sl || [ -n "$_sl" ]; do - src_lines[_sli]="$_sl" - ((++_sli)) - done <"$file" - - local block=0 decision_line _kind arms branch_entry - local -a arm_specs=() - local arm arm_index - while IFS= read -r branch_entry; do - [ -z "$branch_entry" ] && continue - IFS='|' read -r decision_line _kind arms <<<"$branch_entry" - - arm_index=0 - IFS=',' read -ra arm_specs <<<"$arms" - for arm in "${arm_specs[@]}"; do - bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}" - echo "${decision_line}|${block}|${arm_index}|${_BASHUNIT_ARM_TAKEN_OUT}" - arm_index=$((arm_index + 1)) - done - - block=$((block + 1)) - done < <(bashunit::coverage::extract_branches "$file") -} - -function bashunit::coverage::get_percentage() { - local total_executable=0 - local total_hit=0 - - 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") - - 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" -} - -function bashunit::coverage::report_text() { - if ! bashunit::env::is_coverage_enabled; then - return 0 - fi - - local total_executable=0 - local total_hit=0 - local has_files=false - - echo "" - echo "Coverage Report" - echo "---------------" - - local file - while IFS= read -r file; do - { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - has_files=true - - 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#*:}" - - total_executable=$((total_executable + executable)) - total_hit=$((total_hit + hit)) - - local color reset="$_BASHUNIT_COLOR_DEFAULT" - color=$(bashunit::coverage::get_color_for_class "$class") - - # Display relative path - local display_file="${file#"$(pwd)"/}" - printf "%s%-40s %3d/%3d lines (%3d%%)%s\n" \ - "$color" "$display_file" "$hit" "$executable" "$pct" "$reset" - done < <(bashunit::coverage::get_tracked_files) - - if [ "$has_files" != "true" ]; then - echo "---------------" - echo "Total: 0/0 (0%)" - return 0 - fi - - echo "---------------" - - # Total - local total_pct total_class - total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") - total_class=$(bashunit::coverage::get_coverage_class "$total_pct") - - local color reset="$_BASHUNIT_COLOR_DEFAULT" - color=$(bashunit::coverage::get_color_for_class "$total_class") - - printf "%sTotal: %d/%d (%d%%)%s\n" \ - "$color" "$total_hit" "$total_executable" "$total_pct" "$reset" - - # Optional per-function summary (gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS) - if [ "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:-false}" = "true" ]; then - bashunit::coverage::report_text_functions - fi - - # Optional uncovered hotspots (gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED) - if [ "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:-false}" = "true" ]; then - bashunit::coverage::report_text_uncovered - fi - - # Optional per-line execution counts (gated on BASHUNIT_COVERAGE_SHOW_LINE_HITS) - if [ "${BASHUNIT_COVERAGE_SHOW_LINE_HITS:-false}" = "true" ]; then - bashunit::coverage::report_text_line_hits - fi - - # Show report location if generated - if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then - echo "" - echo "Coverage report written to: $BASHUNIT_COVERAGE_REPORT" - fi -} - -# Compress a sorted list of integers into a comma-separated range -# string (e.g. "3 4 5 7 9 10" -> "3-5,7,9-10"). Result on -# _BASHUNIT_RANGES_OUT to avoid a subshell on each call. -_BASHUNIT_RANGES_OUT="" -function bashunit::coverage::_compress_ranges() { - local out="" start="" end="" n - for n in "$@"; do - if [ -z "$start" ]; then - start="$n" - end="$n" - elif [ "$n" -eq $((end + 1)) ]; then - end="$n" - else - if [ "$start" = "$end" ]; then - out="${out}${start}," - else - out="${out}${start}-${end}," - fi - start="$n" - end="$n" - fi - done - if [ -n "$start" ]; then - if [ "$start" = "$end" ]; then - out="${out}${start}" - else - out="${out}${start}-${end}" - fi - fi - _BASHUNIT_RANGES_OUT="${out%,}" -} - -# List executable lines that were never hit, grouped by file. -# Gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Output is suppressed -# when no uncovered lines exist so a fully-covered run stays quiet. -function bashunit::coverage::report_text_uncovered() { - local file - local printed_header=false - while IFS= read -r file; do - { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - - bashunit::coverage::load_hits_by_line "$file" - - local -a uncovered_lines=() - local _ucount=0 - local lineno=0 line - while IFS= read -r line || [ -n "$line" ]; do - lineno=$((lineno + 1)) - bashunit::coverage::is_executable_line "$line" "$lineno" || continue - local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" - if [ "$lh" -eq 0 ]; then - uncovered_lines[_ucount]="$lineno" - _ucount=$((_ucount + 1)) - fi - done <"$file" - - [ "$_ucount" -eq 0 ] && continue - - if [ "$printed_header" != "true" ]; then - echo "" - echo "Uncovered Lines" - echo "---------------" - printed_header=true - fi - - local display_file="${file#"$(pwd)"/}" - local color="$_BASHUNIT_COLOR_FAILED" reset="$_BASHUNIT_COLOR_DEFAULT" - local out - bashunit::coverage::_compress_ranges "${uncovered_lines[@]}" - out="$_BASHUNIT_RANGES_OUT" - - printf "%s%s:%s%s\n" "$color" "$display_file" "$out" "$reset" - done < <(bashunit::coverage::get_tracked_files) -} - -# Per-line execution hit counts, gated on BASHUNIT_COVERAGE_SHOW_LINE_HITS=true. -# Lists each covered executable line as ":", where count is the -# number of times the line ran (the same value LCOV emits in its DA records). -function bashunit::coverage::report_text_line_hits() { - local IFS=$' \t\n' - local file - local printed_header=false - while IFS= read -r file; do - { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - - bashunit::coverage::load_hits_by_line "$file" - - local -a hit_specs=() - local _hc=0 - local lineno=0 line - while IFS= read -r line || [ -n "$line" ]; do - lineno=$((lineno + 1)) - bashunit::coverage::is_executable_line "$line" "$lineno" || continue - local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" - if [ "$lh" -gt 0 ]; then - hit_specs[_hc]="${lineno}:${lh}" - _hc=$((_hc + 1)) - fi - done <"$file" - - [ "$_hc" -eq 0 ] && continue - - if [ "$printed_header" != "true" ]; then - echo "" - echo "Line Hits" - echo "---------" - printed_header=true - fi - - local display_file="${file#"$(pwd)"/}" - printf "%s: %s\n" "$display_file" "${hit_specs[*]}" - done < <(bashunit::coverage::get_tracked_files) -} - -# Per-function coverage summary printed after the file table. -# Gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep default output compact. -function bashunit::coverage::report_text_functions() { - local file - local printed_header=false - while IFS= read -r file; do - { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - - local functions_data - functions_data=$(bashunit::coverage::extract_functions "$file") - [ -z "$functions_data" ] && continue - - bashunit::coverage::load_hits_by_line "$file" - - local -a file_lines=() - local _fli=0 _fl - while IFS= read -r _fl || [ -n "$_fl" ]; do - file_lines[_fli]="$_fl" - ((++_fli)) - done <"$file" - - local display_file="${file#"$(pwd)"/}" - - if [ "$printed_header" != "true" ]; then - echo "" - echo "Functions" - echo "---------" - printed_header=true - fi - echo "${display_file}" - - local fn_name fn_start fn_end ln fn_executable fn_hit - local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" - while IFS='|' read -r fn_name fn_start fn_end; do - [ -z "$fn_name" ] && continue - - fn_executable=0 - fn_hit=0 - for ((ln = fn_start; ln <= fn_end; ln++)); do - bashunit::coverage::is_executable_line \ - "${file_lines[$((ln - 1))]:-}" "$ln" || continue - fn_executable=$((fn_executable + 1)) - [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1)) - done - - fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") - fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") - color=$(bashunit::coverage::get_color_for_class "$fn_class") - - printf " %s%-38s %3d/%3d lines (%3d%%)%s\n" \ - "$color" "$fn_name" "$fn_hit" "$fn_executable" "$fn_pct" "$reset" - done <<<"$functions_data" - done < <(bashunit::coverage::get_tracked_files) -} - -function bashunit::coverage::report_lcov() { - local output_file="${1:-$BASHUNIT_COVERAGE_REPORT}" - - if [ -z "$output_file" ]; then - return 0 - fi - - # Create output directory if needed - mkdir -p "$(dirname "$output_file")" - - # Generate LCOV format - { - echo "TN:" - - while IFS= read -r file; do - { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - - echo "SF:$file" - - bashunit::coverage::load_hits_by_line "$file" - - # Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk - # and buffer the matching FNDA lines for emission after, per - # LCOV convention. - local fn_total=0 fn_hit=0 fn_name fn_start fn_end fln any_hit - local -a fn_dn_records=() - local _fdi=0 - while IFS='|' read -r fn_name fn_start fn_end; do - [ -z "$fn_name" ] && continue - echo "FN:${fn_start},${fn_name}" - fn_total=$((fn_total + 1)) - - any_hit=0 - for ((fln = fn_start; fln <= fn_end; fln++)); do - if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then - any_hit=1 - break - fi - done - fn_dn_records[_fdi]="FNDA:${any_hit},${fn_name}" - _fdi=$((_fdi + 1)) - [ "$any_hit" -eq 1 ] && fn_hit=$((fn_hit + 1)) - done < <(bashunit::coverage::extract_functions "$file") - - local fda - for fda in ${fn_dn_records[@]+"${fn_dn_records[@]}"}; do - echo "$fda" - done - echo "FNF:$fn_total" - echo "FNH:$fn_hit" - - # Branch records (BRDA/BRF/BRH) - local br_total=0 br_hit=0 br_line br_block br_idx br_taken - while IFS='|' read -r br_line br_block br_idx br_taken; do - [ -z "$br_line" ] && continue - echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" - br_total=$((br_total + 1)) - [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) - done < <(bashunit::coverage::compute_branch_hits "$file") - echo "BRF:$br_total" - echo "BRH:$br_hit" - - local lineno=0 executable=0 hit=0 line 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="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" - [ "$lh" -gt 0 ] && ((++hit)) - echo "DA:${lineno},${lh}" - done - - echo "LF:$executable" - echo "LH:$hit" - echo "end_of_record" - done < <(bashunit::coverage::get_tracked_files) - } >"$output_file" -} - -function bashunit::coverage::check_threshold() { - if [ -z "$BASHUNIT_COVERAGE_MIN" ]; then - return 0 - fi - - local pct - pct=$(bashunit::coverage::get_percentage) - - if [ "$pct" -lt "$BASHUNIT_COVERAGE_MIN" ]; then - printf "%sCoverage %d%% is below minimum %d%%%s\n" \ - "$_BASHUNIT_COLOR_FAILED" "$pct" "$BASHUNIT_COVERAGE_MIN" "$_BASHUNIT_COLOR_DEFAULT" - return 1 - fi - - return 0 -} - -# Escape HTML special characters -# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings) -function bashunit::coverage::html_escape() { - local text="$1" - printf "%s" "$text" | sed "s/&/\&/g; s//\>/g" -} - -# Convert file path to safe filename for HTML -function bashunit::coverage::path_to_filename() { - local file="$1" - local display_file="${file#"$(pwd)"/}" - # Replace / with _ and . with _ - local safe_name="${display_file//\//_}" - echo "${safe_name//./_}" -} - -function bashunit::coverage::report_html() { - local output_dir="${1:-coverage/html}" - - if [ -z "$output_dir" ]; then - return 0 - fi - - # Create output directory structure - mkdir -p "$output_dir/files" - - # Collect file data for index - local IFS=$' \t\n' - local total_executable=0 - local total_hit=0 - local -a file_data=() - local file_data_count=0 - local file="" - - while IFS= read -r file; do - { [ -z "$file" ] || [ ! -f "$file" ]; } && continue - - local stats executable hit pct - stats=$(bashunit::coverage::get_cached_stats "$file") - executable="${stats%%:*}" - stats="${stats#*:}" - hit="${stats%%:*}" - stats="${stats#*:}" - pct="${stats%%:*}" - - total_executable=$((total_executable + executable)) - total_hit=$((total_hit + hit)) - - local display_file="${file#"$(pwd)"/}" - local safe_filename - safe_filename=$(bashunit::coverage::path_to_filename "$file") - - file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename" - file_data_count=$((file_data_count + 1)) - - # Generate individual file HTML - bashunit::coverage::generate_file_html "$file" "$output_dir/files/${safe_filename}.html" - done < <(bashunit::coverage::get_tracked_files) - - # Calculate total percentage - local total_pct - total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") - - # Get test results - local tests_passed tests_failed tests_total - tests_passed=$(bashunit::state::get_tests_passed) - tests_failed=$(bashunit::state::get_tests_failed) - tests_total=$((tests_passed + tests_failed)) - - # Generate index.html - bashunit::coverage::generate_index_html \ - "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" \ - "$tests_total" "$tests_passed" "$tests_failed" ${file_data[@]+"${file_data[@]}"} - - echo "Coverage HTML report written to: $output_dir/index.html" -} - -function bashunit::coverage::generate_index_html() { - # Set normal IFS for array operations throughout the function (Bash 3.0/4.3 compatible) - local IFS=$' \t\n' - local output_file="$1" - local total_hit="$2" - local total_executable="$3" - local total_pct="$4" - local tests_total="$5" - local tests_passed="$6" - local tests_failed="$7" - shift 7 - # Handle array passed as arguments - Bash 3.0 compatible - local -a file_data=() - local file_count=0 - if [ $# -gt 0 ]; then - file_data=("$@") - file_count=$# - fi - - # Calculate uncovered lines and file count - local total_uncovered=$((total_executable - total_hit)) - - # Calculate gauge stroke offset (440 is full circle circumference) - local gauge_offset=$((440 - (440 * total_pct / 100))) - - # Determine coverage level and colors for gauge - local total_class gauge_color_start gauge_color_end gauge_text_gradient - total_class=$(bashunit::coverage::get_coverage_class "$total_pct") - case "$total_class" in - high) - gauge_color_start="#10b981" - gauge_color_end="#34d399" - gauge_text_gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)" - ;; - medium) - gauge_color_start="#f59e0b" - gauge_color_end="#fbbf24" - gauge_text_gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)" - ;; - low) - gauge_color_start="#ef4444" - gauge_color_end="#f87171" - gauge_text_gradient="linear-gradient(135deg, #ef4444 0%, #f87171 100%)" - ;; - esac - - { - cat <<'EOF' - - - - - - Coverage Report | bashunit - - - -
-
-
- -EOF - echo "
v${BASHUNIT_VERSION:-0.0.0}
" - cat <<'EOF' -
-

Code Coverage Report

-

Comprehensive line-by-line coverage analysis for your bash scripts

-
-
-
-
-
- - - -EOF - echo " " - echo " " - cat <<'EOF' - - - -EOF - echo " " - cat <<'EOF' - -
-EOF - echo "
${total_pct}%
" - cat <<'EOF' -
Coverage
-
-
-
-

Overall Code Coverage

-EOF - echo "

${total_hit} of ${total_executable} executable lines covered across ${file_count} files.

" - cat <<'EOF' - -
-
-
Coverage Metrics
-
-
- - Total: -EOF - echo " ${total_executable} lines" - cat <<'EOF' -
-
- - Covered: -EOF - echo " ${total_hit} lines" - cat <<'EOF' -
-
- - Uncovered: -EOF - echo " ${total_uncovered} lines" - cat <<'EOF' -
-
-
-
-
Test Results
-
-
- - Files: -EOF - echo " ${file_count}" - cat <<'EOF' -
-
- - Tests: -EOF - echo " ${tests_total} total" - cat <<'EOF' -
-
- - Passed: -EOF - echo " ${tests_passed}" - cat <<'EOF' -
-
- - Failed: -EOF - echo " ${tests_failed}" - cat <<'EOF' -
-
-
-
-
-
-
-
-

File Coverage Details

-
-
- -EOF - echo " ≥${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High" - cat <<'EOF' -
-
- -EOF - echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium" - cat <<'EOF' -
-
- -EOF - echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low" - cat <<'EOF' -
-
-
-
- - - - - - - - - -EOF - - local data display_file hit executable pct safe_filename - for data in ${file_data[@]+"${file_data[@]}"}; do - IFS='|' read -r display_file hit executable pct safe_filename <<<"$data" - - local class - class=$(bashunit::coverage::get_coverage_class "$pct") - - echo " " - echo " " - echo " " - echo " " - echo " " - done - - cat <<'EOF' - -
FileLinesCoverage
" - echo "
" - echo " $(basename "$display_file")" - echo "
./${display_file}
" - echo "
" - echo "
" - echo "
" - echo "
${hit}
" - echo "
of ${executable} lines
" - echo "
" - echo "
" - echo "
" - echo "
" - echo "
" - echo "
" - echo " ${pct}%" - echo "
" - echo "
-
-
-
- - - -EOF - } >"$output_file" -} - -function bashunit::coverage::generate_file_html() { - local file="$1" - local output_file="$2" - - local display_file="${file#"$(pwd)"/}" - 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) - bashunit::coverage::load_hits_by_line "$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) - local -a tests_by_line=() - local _line_and_test - while IFS= read -r _line_and_test; do - [ -z "$_line_and_test" ] && continue - local _tln="${_line_and_test%%|*}" - local _tinfo="${_line_and_test#*|}" - if [ -n "${tests_by_line[_tln]:-}" ]; then - # Append only if not already present (avoid duplicates) - # Use newline boundaries to prevent false positives (e.g., test_foo matching test_foo_bar) - case $'\n'"${tests_by_line[_tln]}"$'\n' in - *$'\n'"$_tinfo"$'\n'*) - # already present, skip - ;; - *) - tests_by_line[_tln]="${tests_by_line[_tln]}"$'\n'"${_tinfo}" - ;; - esac - else - tests_by_line[_tln]="$_tinfo" - fi - done < <(bashunit::coverage::get_all_line_tests "$file") - - # Count total lines and functions - local total_lines - total_lines=$(wc -l <"$file" | tr -d ' ') - local non_executable=$((total_lines - executable)) - - { - cat <<'EOF' - - - - - -EOF - echo " $(basename "$display_file") | Coverage Report" - cat <<'EOF' - - - -
-
- -
-
-EOF - echo " ${pct}%" - cat <<'EOF' - Coverage -
-
-EOF - echo " ${hit}/${executable}" - cat <<'EOF' - Lines -
-
-
-
-
-
-
-
- Line Coverage Progress -EOF - echo " ${pct}%" - cat <<'EOF' -
-
-EOF - echo "
" - cat <<'EOF' -
-
-
-
- -EOF - echo " ${hit} lines covered" - cat <<'EOF' -
-
- -EOF - echo " ${uncovered} lines uncovered" - cat <<'EOF' -
-
- -EOF - echo " ${non_executable} non-executable" - cat <<'EOF' -
-
-
-
-EOF - - # Extract functions and generate summary table - local functions_data - functions_data=$(bashunit::coverage::extract_functions "$file") - - if [ -n "$functions_data" ]; then - cat <<'EOF' -
- - - - - - - - - -EOF - local fn_entry - while IFS= read -r fn_entry; do - [ -z "$fn_entry" ] && continue - local fn_name fn_start fn_end - fn_name="${fn_entry%%|*}" - local rest="${fn_entry#*|}" - fn_start="${rest%%|*}" - fn_end="${rest#*|}" - - # Calculate function coverage using pre-loaded hits data - local fn_executable=0 - local fn_hit=0 - local ln - for ((ln = fn_start; ln <= fn_end; ln++)); do - local ln_content - ln_content="${file_lines[$((ln - 1))]:-}" - if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then - ((++fn_executable)) - local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0} - if [ "$ln_hits" -gt 0 ]; then - ((++fn_hit)) - fi - fi - done - - local fn_pct fn_class row_class - fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") - fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") - case "$fn_class" in - high) row_class="fn-covered" ;; - medium) row_class="fn-partial" ;; - low) row_class="fn-uncovered" ;; - esac - - echo " " - echo " " - echo " " - echo " " - echo " " - done <<<"$functions_data" - - cat <<'EOF' - -
FunctionLinesCoverage
${fn_name}${fn_hit} / ${fn_executable}" - echo "
" - echo "
" - echo " ${fn_pct}%" - echo "
" - echo "
-
-EOF - fi - - cat <<'EOF' -
-
-
-EOF - echo " ./${display_file}" - echo "
" - echo " ${total_lines} total lines" - echo "
" - cat <<'EOF' -
-
- -EOF - - local lineno=0 - local line - for line in "${file_lines[@]}"; do - ((++lineno)) - - local escaped_line - escaped_line=$(bashunit::coverage::html_escape "$line") - - local row_class="" - local hits_display="" - - if bashunit::coverage::is_executable_line "$line" "$lineno"; then - # O(1) lookup from pre-loaded array - local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0} - - if [ "$hits" -gt 0 ]; then - row_class="covered" - - # Check if we have test info for this line - local test_info="${tests_by_line[$lineno]:-}" - if [ -n "$test_info" ]; then - # Build tooltip with test information - local tooltip_html="
Tests hitting this line
    " - local test_file test_fn - while IFS=':' read -r test_file test_fn; do - [ -z "$test_file" ] && continue - local short_file - short_file=$(basename "$test_file") - tooltip_html="$tooltip_html
  • ${short_file}:${test_fn}
  • " - done <<<"$test_info" - tooltip_html="$tooltip_html
" - hits_display="${hits}×${tooltip_html}" - else - hits_display="${hits}×" - fi - else - row_class="uncovered" - hits_display="${hits}×" - fi - fi - - echo " " - echo " " - echo " " - echo " " - echo " " - done - - cat <<'EOF' -
$lineno$hits_display$escaped_line
-
-
-
- - - -EOF - } >"$output_file" -} - -function bashunit::coverage::cleanup() { - if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then - local coverage_dir - coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") - rm -rf "$coverage_dir" - fi -} +# Sourced in dependency layers, leaves first: +# config · paths · lines · functions → engine · stats · branches +# → report_text · report_lcov · report_html → html_index · html_file +source "$BASHUNIT_ROOT_DIR/src/coverage/config.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/paths.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/lines.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/functions.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/engine.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/stats.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/branches.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/report_text.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/report_lcov.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/report_html.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/html_index.sh" +source "$BASHUNIT_ROOT_DIR/src/coverage/html_file.sh" diff --git a/src/coverage/branches.sh b/src/coverage/branches.sh new file mode 100644 index 00000000..99903189 --- /dev/null +++ b/src/coverage/branches.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash + +# Branch coverage extraction and hit computation. See adrs/adr-007-branch-coverage-mvp.md. + + +# Append "start:end" to a comma-separated arms string. Result is +# returned via the global _BASHUNIT_BRANCH_ARMS_OUT to avoid the cost +# of a subshell on a hot per-line path. Bash 3.0 cannot pass arrays +# (or namerefs) by reference, so a single output slot is the cheapest +# portable option. +_BASHUNIT_BRANCH_ARMS_OUT="" + +function bashunit::coverage::_append_arm() { + local existing="$1" arm_start="$2" arm_end="$3" + if [ -z "$existing" ]; then + _BASHUNIT_BRANCH_ARMS_OUT="${arm_start}:${arm_end}" + else + _BASHUNIT_BRANCH_ARMS_OUT="${existing},${arm_start}:${arm_end}" + fi +} + +# Detect whether a trimmed line is a case-pattern opener (ends with +# `)` optionally followed by whitespace and a comment). Avoids +# matching mid-line uses such as `cmd $(other)`. +function bashunit::coverage::_is_case_pattern_line() { + local trimmed="$1" + case "$trimmed" in + *')'*) ;; + *) return 1 ;; + esac + + local before_paren="${trimmed%%')'*}" + local after="${trimmed#"$before_paren"}" + after="${after#)}" + after="${after#"${after%%[![:space:]]*}"}" + case "$after" in + '' | '#'*) return 0 ;; + esac + return 1 +} + + +# Extract branch points from a Bash file. +# Output format: ||:[,:]... +# kind ∈ {if, case, loop} +# Scope: if/elif/else chains, case patterns and loop bodies. +# See adrs/adr-007-branch-coverage-mvp.md. +# The handlers below operate on the per-construct state arrays that +# extract_branches keeps as locals. Bash 3.0 has dynamic scoping for +# `local` vars, so the helpers see and mutate the caller's state +# without needing namerefs (which would require Bash 4.3+). + + +function bashunit::coverage::_branch_push_if() { + local lineno=$1 + if_decision_line[if_depth]=$lineno + if_arms[if_depth]="" + if_arm_start[if_depth]=$((lineno + 1)) + if_depth=$((if_depth + 1)) +} + +function bashunit::coverage::_branch_close_if_arm() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + if_arm_start[idx]=$((lineno + 1)) +} + +function bashunit::coverage::_branch_emit_if() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" + if_depth=$idx +} + +function bashunit::coverage::_branch_push_case() { + local lineno=$1 + case_decision_line[case_depth]=$lineno + case_arms[case_depth]="" + case_arm_start[case_depth]=0 + case_in_pattern[case_depth]=0 + case_depth=$((case_depth + 1)) +} + +function bashunit::coverage::_branch_close_case_arm() { + local lineno=$1 idx=$((case_depth - 1)) + [ "${case_in_pattern[$idx]}" = "1" ] || return 0 + bashunit::coverage::_append_arm \ + "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$((lineno - 1))" + case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + case_in_pattern[idx]=0 +} + +function bashunit::coverage::_branch_emit_case() { + local lineno=$1 idx=$((case_depth - 1)) + bashunit::coverage::_branch_close_case_arm "$lineno" + if [ -n "${case_arms[$idx]}" ]; then + echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" + fi + case_depth=$idx +} + +function bashunit::coverage::_branch_open_case_pattern() { + local lineno=$1 idx=$((case_depth - 1)) + case_arm_start[idx]=$((lineno + 1)) + case_in_pattern[idx]=1 +} + +# A loop (while/until/for/select) is a single-arm branch: its body. The arm is +# taken iff the loop ran at least once (an executable body line was hit); a +# never-taken body is an uncovered zero-iteration branch. Every `done`-closed +# construct must push so `done` pairs with the right opener when nested. +function bashunit::coverage::_branch_push_loop() { + local lineno=$1 + loop_decision_line[loop_depth]=$lineno + loop_arm_start[loop_depth]=$((lineno + 1)) + loop_depth=$((loop_depth + 1)) +} + +function bashunit::coverage::_branch_emit_loop() { + local lineno=$1 idx=$((loop_depth - 1)) + echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))" + loop_depth=$idx +} + +function bashunit::coverage::extract_branches() { + local file="$1" + + local -a lines=() + local _i=0 _l + while IFS= read -r _l || [ -n "$_l" ]; do + lines[_i]="$_l" + ((++_i)) + done <"$file" + local total_lines=$_i + + # State arrays — read and mutated by the _branch_* helpers via Bash's + # dynamic scoping. Each array is keyed by depth so nested constructs + # work without associative arrays. + local -a if_decision_line=() if_arms=() if_arm_start=() + local if_depth=0 + local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() + local case_depth=0 + local -a loop_decision_line=() loop_arm_start=() + local loop_depth=0 + + local lineno=0 line trimmed first + while [ "$lineno" -lt "$total_lines" ]; do + line="${lines[$lineno]}" + lineno=$((lineno + 1)) + + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in '' | '#'*) continue ;; esac + first="${trimmed%%[[:space:]\;]*}" + + # Reserved-word patterns single-quoted to dodge `case ... esac` + # parser confusion. + case "$first" in + 'if') bashunit::coverage::_branch_push_if "$lineno" ;; + 'elif' | 'else') + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_close_if_arm "$lineno" + ;; + 'fi') + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_emit_if "$lineno" + ;; + 'case') bashunit::coverage::_branch_push_case "$lineno" ;; + 'esac') + [ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno" + ;; + 'while' | 'until' | 'for' | 'select') + bashunit::coverage::_branch_push_loop "$lineno" + ;; + 'done') + [ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno" + ;; + *) + [ "$case_depth" -eq 0 ] && continue + case "$trimmed" in + ';;&'* | ';;'* | ';&'*) + bashunit::coverage::_branch_close_case_arm "$lineno" + ;; + *) + if bashunit::coverage::_is_case_pattern_line "$trimmed"; then + bashunit::coverage::_branch_open_case_pattern "$lineno" + fi + ;; + esac + ;; + esac + done +} + + +# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in +# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from +# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see +# load_hits_by_line); caller must have populated the src_lines array in +# scope -- Bash 3.0 cannot pass arrays into a function. Result is +# returned via the global to avoid a per-arm subshell. +_BASHUNIT_ARM_TAKEN_OUT=0 + +function bashunit::coverage::_arm_taken() { + local arm_start="$1" arm_end="$2" ln + for ((ln = arm_start; ln <= arm_end; ln++)); do + bashunit::coverage::is_executable_line \ + "${src_lines[$((ln - 1))]:-}" "$ln" || continue + if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then + _BASHUNIT_ARM_TAKEN_OUT=1 + return + fi + done + _BASHUNIT_ARM_TAKEN_OUT=0 +} + +# Compute branch hit data for a file. +# Output format: ||| +# block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). +# An arm is "taken" iff at least one executable line inside its range +# has a recorded hit. taken_count is 0 or 1 — MVP does not preserve +# per-arm hit counts. +function bashunit::coverage::compute_branch_hits() { + local file="$1" + + bashunit::coverage::load_hits_by_line "$file" + + local -a src_lines=() + local _sli=0 _sl + while IFS= read -r _sl || [ -n "$_sl" ]; do + src_lines[_sli]="$_sl" + ((++_sli)) + done <"$file" + + local block=0 decision_line _kind arms branch_entry + local -a arm_specs=() + local arm arm_index + while IFS= read -r branch_entry; do + [ -z "$branch_entry" ] && continue + IFS='|' read -r decision_line _kind arms <<<"$branch_entry" + + arm_index=0 + IFS=',' read -ra arm_specs <<<"$arms" + for arm in "${arm_specs[@]}"; do + bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}" + echo "${decision_line}|${block}|${arm_index}|${_BASHUNIT_ARM_TAKEN_OUT}" + arm_index=$((arm_index + 1)) + done + + block=$((block + 1)) + done < <(bashunit::coverage::extract_branches "$file") +} diff --git a/src/coverage/config.sh b/src/coverage/config.sh new file mode 100644 index 00000000..f08c3ae2 --- /dev/null +++ b/src/coverage/config.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash + +# Coverage run configuration: data-file locations, tracked-file roots and engine selection. + +# Coverage data storage +# Use :- to preserve inherited values from parent bashunit processes +_BASHUNIT_COVERAGE_DATA_FILE="${_BASHUNIT_COVERAGE_DATA_FILE:-}" +_BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" + +# Simple file-based cache for tracked files (Bash 3.0 compatible) +# The tracked cache file stores files that have already been processed +_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-}" + +# File to store which tests hit each line (for detailed coverage tooltips) +_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" + +# Engine picked once in init and inherited by every worker, so the per-test +# enable path never re-forks resolve_engine +_BASHUNIT_COVERAGE_ENGINE_RESOLVED="${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" + +_BASHUNIT_COVERAGE_IS_PARALLEL="" + +# Auto-discover coverage paths from test file names +# When no explicit coverage paths are set, find source files matching test file base names +# Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh +function bashunit::coverage::auto_discover_paths() { + local project_root + project_root="$(pwd)" + local -a discovered_paths=() + local discovered_paths_count=0 + local test_file + + for test_file in "$@"; do + # Extract base name: tests/unit/assert_test.sh -> assert_test.sh + local file_basename + file_basename=$(basename "$test_file") + + # Remove test suffixes to get source name: assert_test.sh -> assert + local source_name="${file_basename%_test.sh}" + [ "$source_name" = "$file_basename" ] && source_name="${file_basename%Test.sh}" + [ "$source_name" = "$file_basename" ] && continue # Not a test file pattern + + # Find matching source files recursively + local found_file + while IFS= read -r -d '' found_file; do + # Skip test files and vendor directories + case "$found_file" in + *test* | *Test* | *vendor* | *node_modules*) continue ;; + esac + discovered_paths[discovered_paths_count]="$found_file" + discovered_paths_count=$((discovered_paths_count + 1)) + done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null) + done + + # Return unique paths, comma-separated + if [ "$discovered_paths_count" -gt 0 ]; then + printf '%s\n' "${discovered_paths[@]}" | sort -u | tr '\n' ',' | sed 's/,$//' + fi +} + +function bashunit::coverage::init() { + if ! bashunit::env::is_coverage_enabled; then + return 0 + fi + + # Skip coverage init if we're a subprocess of another coverage-enabled bashunit + # This prevents nested bashunit calls (e.g., in acceptance tests) from + # interfering with the parent's coverage tracking + if [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ]; then + export BASHUNIT_COVERAGE=false + return 0 + fi + + # Create coverage data directory with unique name via mktemp -d + # (avoids $$-$RANDOM collisions and symlink races in shared temp dirs) + local coverage_dir + coverage_dir=$("${MKTEMP:-mktemp}" -d "${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}/bashunit-coverage.XXXXXXXX") + + _BASHUNIT_COVERAGE_DATA_FILE="${coverage_dir}/hits.dat" + _BASHUNIT_COVERAGE_TRACKED_FILES="${coverage_dir}/files.dat" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${coverage_dir}/cache.dat" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="${coverage_dir}/test_hits.dat" + + # Initialize empty files + : >"$_BASHUNIT_COVERAGE_DATA_FILE" + : >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + : >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + : >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + + # Reset in-memory caches and buffers + _BASHUNIT_COVERAGE_BUFFER="" + _BASHUNIT_COVERAGE_BUFFER_COUNT=0 + _BASHUNIT_COVERAGE_HITS_BUFFER="" + _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="" + + _BASHUNIT_COVERAGE_ENGINE_RESOLVED=$(bashunit::coverage::resolve_engine) + + export _BASHUNIT_COVERAGE_DATA_FILE + export _BASHUNIT_COVERAGE_TRACKED_FILES + export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE + export _BASHUNIT_COVERAGE_TEST_HITS_FILE + export _BASHUNIT_COVERAGE_ENGINE_RESOLVED +} + +## +# Whether this Bash can send xtrace to a private file descriptor. +# BASH_XTRACEFD and the {fd}> auto-allocation it relies on both landed in 4.1; +# below that, xtrace can only reach stderr, where it would intermix with the +# output of the code under test. +# Returns: 0 when supported, 1 otherwise +## +function bashunit::coverage::xtrace_is_supported() { + if [ "${BASH_VERSINFO[0]}" -gt 4 ]; then + return 0 + fi + [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 1 ] +} + +## +# Resolve BASHUNIT_COVERAGE_ENGINE (auto|xtrace|trap) to the engine to run. +# Anything unrecognised, and any xtrace request this Bash cannot honour, falls +# back to the trap engine: it is slower but available everywhere. +# Returns: prints "xtrace" or "trap" +## +function bashunit::coverage::resolve_engine() { + case "${BASHUNIT_COVERAGE_ENGINE:-auto}" in + xtrace | auto) + if bashunit::coverage::xtrace_is_supported; then + echo "xtrace" + else + echo "trap" + fi + ;; + *) echo "trap" ;; + esac +} diff --git a/src/coverage/engine.sh b/src/coverage/engine.sh new file mode 100644 index 00000000..2bc1d3d4 --- /dev/null +++ b/src/coverage/engine.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash + +# Line capture: DEBUG-trap and xtrace engines, buffering, teardown and parallel merge. + +# In-memory buffer for coverage data (reduces file I/O) +_BASHUNIT_COVERAGE_BUFFER="" +_BASHUNIT_COVERAGE_BUFFER_COUNT=0 +_BASHUNIT_COVERAGE_BUFFER_LIMIT=100 +_BASHUNIT_COVERAGE_HITS_BUFFER="" + + +# Field separator inside an xtrace PS4 prefix. A control character keeps the +# parse correct for paths containing spaces or colons. +_BASHUNIT_COVERAGE_XTRACE_FS=$'\034' +# Bash replicates only PS4's *first* character once per nesting level, so the +# literal '|' that follows it always marks where the path begins, whatever the +# trace depth. +_BASHUNIT_COVERAGE_XTRACE_PS4='@|${BASH_SOURCE}'"$_BASHUNIT_COVERAGE_XTRACE_FS" +_BASHUNIT_COVERAGE_XTRACE_PS4="$_BASHUNIT_COVERAGE_XTRACE_PS4"'${LINENO}'"$_BASHUNIT_COVERAGE_XTRACE_FS"' ' + +# Per-shell xtrace state. Empty FD means the xtrace engine is not active, which +# is what disable_trap dispatches on. +_BASHUNIT_COVERAGE_XTRACE_FD="" +_BASHUNIT_COVERAGE_XTRACE_FILE="" +_BASHUNIT_COVERAGE_XTRACE_SAVED_PS4="" + +# Return slots for _resolve_output_files +_BASHUNIT_COVERAGE_DATA_TARGET_OUT="" +_BASHUNIT_COVERAGE_HITS_TARGET_OUT="" + + +# Name kept from the trap-only era: this is the seam runner/exec.sh and +# runner/hooks.sh already call around every test body and lifecycle hook. +function bashunit::coverage::enable_trap() { + if ! bashunit::env::is_coverage_enabled; then + return 0 + fi + + local engine="${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" + if [ -z "$engine" ]; then + engine=$(bashunit::coverage::resolve_engine) + fi + + if [ "$engine" = "xtrace" ]; then + bashunit::coverage::_enable_xtrace + return 0 + fi + + # Enable trap inheritance into functions + set -T + + # Set DEBUG trap to record line execution + # Use ${VAR:-} to handle unset variables when set -u is active (in subshells) + # shellcheck disable=SC2154 + trap 'bashunit::coverage::record_line "${BASH_SOURCE[0]:-}" "${LINENO:-}"' DEBUG +} + +function bashunit::coverage::disable_trap() { + if [ -n "$_BASHUNIT_COVERAGE_XTRACE_FD" ]; then + bashunit::coverage::_disable_xtrace + return 0 + fi + + trap - DEBUG + set +T + # Flush any remaining buffered coverage data + bashunit::coverage::flush_buffer +} + +# Point xtrace at a per-worker trace file and mark the test boundary. +# +# The trace is *appended to and never parsed here*: parsing per test costs an +# awk fork plus a full pass per test, which measured ~27x the cost of capturing +# and cancelled the engine's whole reason to exist. Attribution that the trap +# engine reads from globals is written into the trace as a sentinel instead, and +# bashunit::coverage::finalize does one pass over everything after the run. +# +# Concurrent workers only ever run within a single test file, so the dispatcher +# ordinal that already names their .result files also keeps traces apart — +# BASHPID is Bash 4.0+ and banned by the compatibility gate. +function bashunit::coverage::_enable_xtrace() { + local coverage_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" + _BASHUNIT_COVERAGE_XTRACE_FILE="${coverage_dir}/xtrace.$$.${_BASHUNIT_RUNNER_RESULT_ORDINAL:-0}.trace" + + exec {_BASHUNIT_COVERAGE_XTRACE_FD}>>"$_BASHUNIT_COVERAGE_XTRACE_FILE" + + local test_ctx="" + if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && + [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then + test_ctx="${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" + fi + builtin printf '%sTEST%s%s\n' "$_BASHUNIT_COVERAGE_XTRACE_FS" \ + "$_BASHUNIT_COVERAGE_XTRACE_FS" "$test_ctx" >&"$_BASHUNIT_COVERAGE_XTRACE_FD" + + _BASHUNIT_COVERAGE_XTRACE_SAVED_PS4="${PS4:-}" + BASH_XTRACEFD=$_BASHUNIT_COVERAGE_XTRACE_FD + PS4=$_BASHUNIT_COVERAGE_XTRACE_PS4 + set -x +} + +# Stop tracing and undo anything the test body may have changed. Restoring PS4 +# is what keeps a test that sets its own xtrace from corrupting later traces. +function bashunit::coverage::_disable_xtrace() { + set +x + PS4=$_BASHUNIT_COVERAGE_XTRACE_SAVED_PS4 + + local fd="$_BASHUNIT_COVERAGE_XTRACE_FD" + _BASHUNIT_COVERAGE_XTRACE_FD="" + _BASHUNIT_COVERAGE_XTRACE_FILE="" + + unset BASH_XTRACEFD + exec {fd}>&- +} + + +# Collect the distinct source paths a trace mentions, so the shell can make the +# should_track decision awk cannot. +_BASHUNIT_COVERAGE_XTRACE_PATHS_AWK=' +{ + if (substr($0, 1, 1) != "@") { next } + i = 1 + while (substr($0, i, 1) == "@") { i++ } + if (substr($0, i, 1) != "|") { next } + rest = substr($0, i + 1) + p = index(rest, fsc) + if (p == 0) { next } + path = substr(rest, 1, p - 1) + if (path != "" && !(path in seen)) { seen[path] = 1; print path } +} +' + +# Emit hit records, attributing each to the test named by the last sentinel. +_BASHUNIT_COVERAGE_XTRACE_EMIT_AWK=' +FILENAME == map_file { + tab = index($0, "\t") + if (tab > 0) { + norm = substr($0, tab + 1) + if (norm != "-") { tracked[substr($0, 1, tab - 1)] = norm } + } + next +} +substr($0, 1, 1) == fsc { + head = substr($0, 2) + if (substr(head, 1, 5) == "TEST" fsc) { ctx = substr(head, 6); next } +} +{ + if (substr($0, 1, 1) != "@") { next } + i = 1 + while (substr($0, i, 1) == "@") { i++ } + if (substr($0, i, 1) != "|") { next } + rest = substr($0, i + 1) + p = index(rest, fsc) + if (p == 0) { next } + path = substr(rest, 1, p - 1) + if (!(path in tracked)) { next } + tail = substr(rest, p + 1) + q = index(tail, fsc) + if (q == 0) { next } + line = substr(tail, 1, q - 1) + if (line == "") { next } + record = tracked[path] ":" line + print record >> data_out + if (ctx != "") { print record "|" ctx >> hits_out } +} +' + + +## +# Fold every captured xtrace into the hit records the reports read. +# No-op unless the xtrace engine ran. Must be called after the last test and +# before aggregate_parallel, from the main shell. +## +function bashunit::coverage::finalize() { + if [ "${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" != "xtrace" ]; then + return 0 + fi + [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] || return 0 + + local coverage_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" + local -a traces=() + local trace + for trace in "$coverage_dir"/xtrace.*.trace; do + [ -f "$trace" ] || continue + traces[${#traces[@]}]="$trace" + done + [ "${#traces[@]}" -gt 0 ] || return 0 + + local map_file="${coverage_dir}/xtrace-map.dat" + local paths_file="${coverage_dir}/xtrace-paths.dat" + : >"$map_file" + + "$AWK" -v fsc="$_BASHUNIT_COVERAGE_XTRACE_FS" \ + "$_BASHUNIT_COVERAGE_XTRACE_PATHS_AWK" "${traces[@]}" >"$paths_file" + + local path + while IFS= read -r path; do + [ -n "$path" ] || continue + if bashunit::coverage::should_track "$path"; then + builtin printf '%s\t%s\n' "$path" \ + "$(bashunit::coverage::normalize_path "$path")" >>"$map_file" + else + builtin printf '%s\t-\n' "$path" >>"$map_file" + fi + done <"$paths_file" + + "$AWK" \ + -v map_file="$map_file" \ + -v fsc="$_BASHUNIT_COVERAGE_XTRACE_FS" \ + -v data_out="$_BASHUNIT_COVERAGE_DATA_FILE" \ + -v hits_out="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" \ + "$_BASHUNIT_COVERAGE_XTRACE_EMIT_AWK" "$map_file" "${traces[@]}" + + rm -f "$paths_file" "${traces[@]}" +} + +function bashunit::coverage::record_line() { + local file="$1" + local lineno="$2" + + # Skip if no file or line + { [ -z "$file" ] || [ -z "$lineno" ]; } && return 0 + + # Skip if coverage data file doesn't exist (trap inherited by child process) + [ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ] && return 0 + + # Fast in-memory should_track cache (avoids grep + file I/O per line) + case "$_BASHUNIT_COVERAGE_TRACK_CACHE" in + *"|${file}:0|"*) return 0 ;; + *"|${file}:1|"*) ;; + *) + # Not cached yet — run full check and cache result + if bashunit::coverage::should_track "$file"; then + _BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:1|" + else + _BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:0|" + return 0 + fi + ;; + esac + + # Fast in-memory path normalization cache (avoids cd + pwd subshell per line) + local normalized_file="" + case "$_BASHUNIT_COVERAGE_PATH_CACHE" in + *"|${file}="*) + # Extract cached value + normalized_file="${_BASHUNIT_COVERAGE_PATH_CACHE#*"|${file}="}" + normalized_file="${normalized_file%%"|"*}" + ;; + *) + normalized_file=$(bashunit::coverage::normalize_path "$file") + _BASHUNIT_COVERAGE_PATH_CACHE="${_BASHUNIT_COVERAGE_PATH_CACHE}|${file}=${normalized_file}|" + ;; + esac + + # Buffer the coverage data in memory + _BASHUNIT_COVERAGE_BUFFER="${_BASHUNIT_COVERAGE_BUFFER}${normalized_file}:${lineno} +" + # Also buffer test hit data if in a test context + if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && + [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then + _BASHUNIT_COVERAGE_HITS_BUFFER="${_BASHUNIT_COVERAGE_HITS_BUFFER}\ +${normalized_file}:${lineno}|\ +${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN} +" + fi + + _BASHUNIT_COVERAGE_BUFFER_COUNT=$((_BASHUNIT_COVERAGE_BUFFER_COUNT + 1)) + + # Flush buffer to disk when threshold is reached + if [ "$_BASHUNIT_COVERAGE_BUFFER_COUNT" -ge \ + "$_BASHUNIT_COVERAGE_BUFFER_LIMIT" ]; then + bashunit::coverage::flush_buffer + fi +} + +# Resolve the parallel-safe destinations for hit records into the return slots +# _BASHUNIT_COVERAGE_DATA_TARGET_OUT and _BASHUNIT_COVERAGE_HITS_TARGET_OUT. +function bashunit::coverage::_resolve_output_files() { + # Cache the parallel check to avoid function calls + if [ -z "$_BASHUNIT_COVERAGE_IS_PARALLEL" ]; then + if bashunit::parallel::is_enabled; then + _BASHUNIT_COVERAGE_IS_PARALLEL="yes" + else + _BASHUNIT_COVERAGE_IS_PARALLEL="no" + fi + fi + + if [ "$_BASHUNIT_COVERAGE_IS_PARALLEL" = "yes" ]; then + _BASHUNIT_COVERAGE_DATA_TARGET_OUT="${_BASHUNIT_COVERAGE_DATA_FILE}.$$" + _BASHUNIT_COVERAGE_HITS_TARGET_OUT="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$" + else + _BASHUNIT_COVERAGE_DATA_TARGET_OUT="$_BASHUNIT_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_HITS_TARGET_OUT="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + fi +} + +function bashunit::coverage::flush_buffer() { + [ -z "$_BASHUNIT_COVERAGE_BUFFER" ] && return 0 + + bashunit::coverage::_resolve_output_files + local data_file="$_BASHUNIT_COVERAGE_DATA_TARGET_OUT" + local test_hits_file="$_BASHUNIT_COVERAGE_HITS_TARGET_OUT" + + # Write buffered data in a single I/O operation. + # Use `builtin printf` so a user test spying/mocking the printf builtin + # cannot shadow the coverage write and silently drop data (see issue #724). + builtin printf '%s' "$_BASHUNIT_COVERAGE_BUFFER" >>"$data_file" + + if [ -n "$_BASHUNIT_COVERAGE_HITS_BUFFER" ]; then + builtin printf '%s' "$_BASHUNIT_COVERAGE_HITS_BUFFER" >>"$test_hits_file" + fi + + # Reset buffer + _BASHUNIT_COVERAGE_BUFFER="" + _BASHUNIT_COVERAGE_HITS_BUFFER="" + _BASHUNIT_COVERAGE_BUFFER_COUNT=0 +} + +function bashunit::coverage::aggregate_parallel() { + # Aggregate per-process coverage files created during parallel execution + local base_file="$_BASHUNIT_COVERAGE_DATA_FILE" + local tracked_base="$_BASHUNIT_COVERAGE_TRACKED_FILES" + local test_hits_base="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + + # Find and merge all per-process coverage data files + # Use nullglob to handle case when no files match + local pid_files pid_file + pid_files=$(ls -1 "${base_file}."* 2>/dev/null) || true + if [ -n "$pid_files" ]; then + while IFS= read -r pid_file; do + [ -f "$pid_file" ] || continue + cat "$pid_file" >>"$base_file" + rm -f "$pid_file" + done <<<"$pid_files" + fi + + # Find and merge all per-process tracked files lists + pid_files=$(ls -1 "${tracked_base}."* 2>/dev/null) || true + if [ -n "$pid_files" ]; then + while IFS= read -r pid_file; do + [ -f "$pid_file" ] || continue + cat "$pid_file" >>"$tracked_base" + rm -f "$pid_file" + done <<<"$pid_files" + fi + + # Find and merge all per-process test hits files + if [ -n "$test_hits_base" ]; then + pid_files=$(ls -1 "${test_hits_base}."* 2>/dev/null) || true + if [ -n "$pid_files" ]; then + while IFS= read -r pid_file; do + [ -f "$pid_file" ] || continue + cat "$pid_file" >>"$test_hits_base" + rm -f "$pid_file" + done <<<"$pid_files" + fi + fi + + # Deduplicate tracked files + if [ -f "$tracked_base" ]; then + sort -u "$tracked_base" -o "$tracked_base" + fi +} + +function bashunit::coverage::cleanup() { + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" + fi +} diff --git a/src/coverage/functions.sh b/src/coverage/functions.sh new file mode 100644 index 00000000..9a8b3f7b --- /dev/null +++ b/src/coverage/functions.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +# Locating function definitions and their line spans, for the reports. + +# Extract function definitions from a bash file +# Output format: function_name:start_line:end_line (one per function) +function bashunit::coverage::extract_functions() { + local file="$1" + + local lineno=0 + local in_function=0 + local brace_count=0 + local current_fn="" + local fn_start=0 + local line + + while IFS= read -r line || [ -n "$line" ]; do + ((++lineno)) + + # Check for function definition patterns + # Pattern 1: function name() { or function name { + # Pattern 2: name() { or name () { + if [ "$in_function" -eq 0 ]; then + local fn_name="" + + # 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 + in_function=1 + current_fn="$fn_name" + fn_start=$lineno + brace_count=0 + + # Count opening braces on this line + local open_braces="${line//[^\{]/}" + local close_braces="${line//[^\}]/}" + local open_count=${#open_braces} + local close_count=${#close_braces} + brace_count=$((brace_count + open_count - close_count)) + + # 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}" + in_function=0 + current_fn="" + fi + continue + fi + fi + + # Track braces inside function + if [ "$in_function" -eq 1 ]; then + local open_braces="${line//[^\{]/}" + local close_braces="${line//[^\}]/}" + brace_count=$((brace_count + ${#open_braces} - ${#close_braces})) + + # Function ended + if [ "$brace_count" -le 0 ]; then + echo "${current_fn}|${fn_start}|${lineno}" + in_function=0 + current_fn="" + brace_count=0 + fi + fi + done <"$file" + + # Handle unclosed function (shouldn't happen in valid code) + if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then + echo "${current_fn}|${fn_start}|${lineno}" + fi +} diff --git a/src/coverage/html_file.sh b/src/coverage/html_file.sh new file mode 100644 index 00000000..6e6ab026 --- /dev/null +++ b/src/coverage/html_file.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash + +# HTML coverage report: the per-file page. + +function bashunit::coverage::generate_file_html() { + local file="$1" + local output_file="$2" + + local display_file="${file#"$(pwd)"/}" + 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) + bashunit::coverage::load_hits_by_line "$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) + local -a tests_by_line=() + local _line_and_test + while IFS= read -r _line_and_test; do + [ -z "$_line_and_test" ] && continue + local _tln="${_line_and_test%%|*}" + local _tinfo="${_line_and_test#*|}" + if [ -n "${tests_by_line[_tln]:-}" ]; then + # Append only if not already present (avoid duplicates) + # Use newline boundaries to prevent false positives (e.g., test_foo matching test_foo_bar) + case $'\n'"${tests_by_line[_tln]}"$'\n' in + *$'\n'"$_tinfo"$'\n'*) + # already present, skip + ;; + *) + tests_by_line[_tln]="${tests_by_line[_tln]}"$'\n'"${_tinfo}" + ;; + esac + else + tests_by_line[_tln]="$_tinfo" + fi + done < <(bashunit::coverage::get_all_line_tests "$file") + + # Count total lines and functions + local total_lines + total_lines=$(wc -l <"$file" | tr -d ' ') + local non_executable=$((total_lines - executable)) + + { + cat <<'EOF' + + + + + +EOF + echo " $(basename "$display_file") | Coverage Report" + cat <<'EOF' + + + +
+
+ +
+
+EOF + echo " ${pct}%" + cat <<'EOF' + Coverage +
+
+EOF + echo " ${hit}/${executable}" + cat <<'EOF' + Lines +
+
+
+
+
+
+
+
+ Line Coverage Progress +EOF + echo " ${pct}%" + cat <<'EOF' +
+
+EOF + echo "
" + cat <<'EOF' +
+
+
+
+ +EOF + echo " ${hit} lines covered" + cat <<'EOF' +
+
+ +EOF + echo " ${uncovered} lines uncovered" + cat <<'EOF' +
+
+ +EOF + echo " ${non_executable} non-executable" + cat <<'EOF' +
+
+
+
+EOF + + # Extract functions and generate summary table + local functions_data + functions_data=$(bashunit::coverage::extract_functions "$file") + + if [ -n "$functions_data" ]; then + cat <<'EOF' +
+ + + + + + + + + +EOF + local fn_entry + while IFS= read -r fn_entry; do + [ -z "$fn_entry" ] && continue + local fn_name fn_start fn_end + fn_name="${fn_entry%%|*}" + local rest="${fn_entry#*|}" + fn_start="${rest%%|*}" + fn_end="${rest#*|}" + + # Calculate function coverage using pre-loaded hits data + local fn_executable=0 + local fn_hit=0 + local ln + for ((ln = fn_start; ln <= fn_end; ln++)); do + local ln_content + ln_content="${file_lines[$((ln - 1))]:-}" + if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then + ((++fn_executable)) + local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0} + if [ "$ln_hits" -gt 0 ]; then + ((++fn_hit)) + fi + fi + done + + local fn_pct fn_class row_class + fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") + fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") + case "$fn_class" in + high) row_class="fn-covered" ;; + medium) row_class="fn-partial" ;; + low) row_class="fn-uncovered" ;; + esac + + echo " " + echo " " + echo " " + echo " " + echo " " + done <<<"$functions_data" + + cat <<'EOF' + +
FunctionLinesCoverage
${fn_name}${fn_hit} / ${fn_executable}" + echo "
" + echo "
" + echo " ${fn_pct}%" + echo "
" + echo "
+
+EOF + fi + + cat <<'EOF' +
+
+
+EOF + echo " ./${display_file}" + echo "
" + echo " ${total_lines} total lines" + echo "
" + cat <<'EOF' +
+
+ +EOF + + local lineno=0 + local line + for line in "${file_lines[@]}"; do + ((++lineno)) + + local escaped_line + escaped_line=$(bashunit::coverage::html_escape "$line") + + local row_class="" + local hits_display="" + + if bashunit::coverage::is_executable_line "$line" "$lineno"; then + # O(1) lookup from pre-loaded array + local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0} + + if [ "$hits" -gt 0 ]; then + row_class="covered" + + # Check if we have test info for this line + local test_info="${tests_by_line[$lineno]:-}" + if [ -n "$test_info" ]; then + # Build tooltip with test information + local tooltip_html="
Tests hitting this line
    " + local test_file test_fn + while IFS=':' read -r test_file test_fn; do + [ -z "$test_file" ] && continue + local short_file + short_file=$(basename "$test_file") + tooltip_html="$tooltip_html
  • ${short_file}:${test_fn}
  • " + done <<<"$test_info" + tooltip_html="$tooltip_html
" + hits_display="${hits}×${tooltip_html}" + else + hits_display="${hits}×" + fi + else + row_class="uncovered" + hits_display="${hits}×" + fi + fi + + echo " " + echo " " + echo " " + echo " " + echo " " + done + + cat <<'EOF' +
$lineno$hits_display$escaped_line
+
+
+
+ + + +EOF + } >"$output_file" +} diff --git a/src/coverage/html_index.sh b/src/coverage/html_index.sh new file mode 100644 index 00000000..04678e84 --- /dev/null +++ b/src/coverage/html_index.sh @@ -0,0 +1,366 @@ +#!/usr/bin/env bash + +# HTML coverage report: the index page. + +function bashunit::coverage::generate_index_html() { + # Set normal IFS for array operations throughout the function (Bash 3.0/4.3 compatible) + local IFS=$' \t\n' + local output_file="$1" + local total_hit="$2" + local total_executable="$3" + local total_pct="$4" + local tests_total="$5" + local tests_passed="$6" + local tests_failed="$7" + shift 7 + # Handle array passed as arguments - Bash 3.0 compatible + local -a file_data=() + local file_count=0 + if [ $# -gt 0 ]; then + file_data=("$@") + file_count=$# + fi + + # Calculate uncovered lines and file count + local total_uncovered=$((total_executable - total_hit)) + + # Calculate gauge stroke offset (440 is full circle circumference) + local gauge_offset=$((440 - (440 * total_pct / 100))) + + # Determine coverage level and colors for gauge + local total_class gauge_color_start gauge_color_end gauge_text_gradient + total_class=$(bashunit::coverage::get_coverage_class "$total_pct") + case "$total_class" in + high) + gauge_color_start="#10b981" + gauge_color_end="#34d399" + gauge_text_gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)" + ;; + medium) + gauge_color_start="#f59e0b" + gauge_color_end="#fbbf24" + gauge_text_gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)" + ;; + low) + gauge_color_start="#ef4444" + gauge_color_end="#f87171" + gauge_text_gradient="linear-gradient(135deg, #ef4444 0%, #f87171 100%)" + ;; + esac + + { + cat <<'EOF' + + + + + + Coverage Report | bashunit + + + +
+
+
+ +EOF + echo "
v${BASHUNIT_VERSION:-0.0.0}
" + cat <<'EOF' +
+

Code Coverage Report

+

Comprehensive line-by-line coverage analysis for your bash scripts

+
+
+
+
+
+ + + +EOF + echo " " + echo " " + cat <<'EOF' + + + +EOF + echo " " + cat <<'EOF' + +
+EOF + echo "
${total_pct}%
" + cat <<'EOF' +
Coverage
+
+
+
+

Overall Code Coverage

+EOF + echo "

${total_hit} of ${total_executable} executable lines covered across ${file_count} files.

" + cat <<'EOF' + +
+
+
Coverage Metrics
+
+
+ + Total: +EOF + echo " ${total_executable} lines" + cat <<'EOF' +
+
+ + Covered: +EOF + echo " ${total_hit} lines" + cat <<'EOF' +
+
+ + Uncovered: +EOF + echo " ${total_uncovered} lines" + cat <<'EOF' +
+
+
+
+
Test Results
+
+
+ + Files: +EOF + echo " ${file_count}" + cat <<'EOF' +
+
+ + Tests: +EOF + echo " ${tests_total} total" + cat <<'EOF' +
+
+ + Passed: +EOF + echo " ${tests_passed}" + cat <<'EOF' +
+
+ + Failed: +EOF + echo " ${tests_failed}" + cat <<'EOF' +
+
+
+
+
+
+
+
+

File Coverage Details

+
+
+ +EOF + echo " ≥${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High" + cat <<'EOF' +
+
+ +EOF + echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium" + cat <<'EOF' +
+
+ +EOF + echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low" + cat <<'EOF' +
+
+
+
+ + + + + + + + + +EOF + + local data display_file hit executable pct safe_filename + for data in ${file_data[@]+"${file_data[@]}"}; do + IFS='|' read -r display_file hit executable pct safe_filename <<<"$data" + + local class + class=$(bashunit::coverage::get_coverage_class "$pct") + + echo " " + echo " " + echo " " + echo " " + echo " " + done + + cat <<'EOF' + +
FileLinesCoverage
" + echo "
" + echo " $(basename "$display_file")" + echo "
./${display_file}
" + echo "
" + echo "
" + echo "
" + echo "
${hit}
" + echo "
of ${executable} lines
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
" + echo " ${pct}%" + echo "
" + echo "
+
+
+
+ + + +EOF + } >"$output_file" +} diff --git a/src/coverage/lines.sh b/src/coverage/lines.sh new file mode 100644 index 00000000..f11fc82f --- /dev/null +++ b/src/coverage/lines.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash + +# Static line classification and reading recorded hit data. + + +# Pre-compiled combined regex of all non-executable line patterns. +# Collapses multiple grep subshells into a single invocation per line for performance. +# Each alternation is fully self-anchored so semantics match the original per-pattern checks. +# Patterns covered (in order): +# - comment-only lines (including shebang) +# - function declarations (but not single-line functions with a body) +# - brace-only lines +# - control flow keywords (then, else, fi, do, done, esac, in, ;;, ;;&, ;&) +# - loop terminators with redirection/pipe/fd (e.g. "done < file", "done | sort") +# - case patterns like "--option)" or "*) # comment" +# - standalone ) for arrays/subshells +_BASHUNIT_COVERAGE_NONEXEC_PATTERN='^[[:space:]]*#' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*(function[[:space:]]+)?' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'[a-zA-Z_][a-zA-Z0-9_:]*' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*$' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*[\{\}][[:space:]]*$' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'(then|else|fi|do|done|esac|in|;;|;;&|;&)' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'[[:space:]]*(#.*)?$' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*done' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'[[:space:]]+[^[:space:]#].*$' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*[^\)]+\)[[:space:]]*(#.*)?$' +_BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*\)[[:space:]]*(#.*)?$' + + +# Check if a line is executable (used by get_executable_lines and report_lcov) +# Arguments: line content, line number +# Returns: 0 if executable, 1 if not +function bashunit::coverage::is_executable_line() { + local line="$1" + local lineno="$2" + + # Unused but kept for API compatibility + : "$lineno" + + # Skip empty lines (line with only whitespace) — built-in, no subshell + [ -z "${line// /}" ] && 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 +} + +function bashunit::coverage::get_executable_lines() { + local file="$1" + local count=0 + local lineno=0 + local line + + while IFS= read -r line || [ -n "$line" ]; do + ((++lineno)) + bashunit::coverage::is_executable_line "$line" "$lineno" && ((++count)) + done <"$file" + + echo "$count" +} + +function bashunit::coverage::get_hit_lines() { + local file="$1" + + if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then + echo "0" + return + fi + + # Get unique hit line numbers + local hit_lines + hit_lines=$( (grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null || true) | + cut -d: -f2 | sort -u) + + if [ -z "$hit_lines" ]; then + echo "0" + return + fi + + # 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="${file_lines[$((line_num - 1))]:-}" + [ -z "$line_content" ] && continue + if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then + ((++count)) + fi + done + + echo "$count" +} + +# Compute executable + hit counts for a file in a single source-file pass. +# Reuses get_all_line_hits to avoid scanning the coverage data per line. +# Output format: "executable:hit" +function bashunit::coverage::compute_file_coverage() { + local file="$1" + + bashunit::coverage::load_hits_by_line "$file" + + local executable=0 hit=0 lineno=0 line line_hits + 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)) + line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0} + [ "$line_hits" -gt 0 ] && ((++hit)) + done + + echo "${executable}:${hit}" +} + +# Detect whether a source line ends with a Bash line-continuation, i.e. an +# odd number of unescaped trailing backslashes with no trailing whitespace. +# Comment lines never continue. Used to propagate coverage hits from a +# statement's starting line to its continuation lines (see #722). +function bashunit::coverage::_ends_with_continuation() { + local line="$1" + local lead="${line#"${line%%[![:space:]]*}"}" + case "$lead" in '#'*) return 1 ;; esac + local trailing="${line##*[!\\]}" + case "$line" in *[!\\]*) : ;; *) trailing="$line" ;; esac + [ $((${#trailing} % 2)) -eq 1 ] +} + +# Get all line hits for a file in one pass (performance optimization) +# Output format: one "lineno:count" per line +# +# Bash's DEBUG trap attributes a multi-line statement's execution to the line +# where the statement starts; backslash continuation lines never receive their +# own hit. To match the report's expectation that continuation lines are +# covered, the start line's count is propagated forward across the +# continuation chain (see #722). +function bashunit::coverage::get_all_line_hits() { + local file="$1" + + if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then + return + fi + + # Extract all lines for this file, count occurrences of each line number. + local -a counts=() + local count lineno maxln=0 + while read -r count lineno; do + if [ -n "$lineno" ]; then + counts[lineno]=$count + [ "$lineno" -gt "$maxln" ] && maxln=$lineno + fi + done < <(grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | + cut -d: -f2 | sort | uniq -c) + + if [ "$maxln" -eq 0 ]; then + return + fi + + # Read the source so continuation lines can be detected. + local -a src=() + local _i=0 _l + while IFS= read -r _l || [ -n "$_l" ]; do + src[_i]="$_l" + ((++_i)) + done <"$file" + + local total=$_i + [ "$maxln" -gt "$total" ] && total=$maxln + + # Propagate each start line's count forward across its continuation chain. + local carry=0 idx h + for ((idx = 1; idx <= total; idx++)); do + h=${counts[idx]:-0} + if [ "$carry" -gt 0 ] && [ "$h" -lt "$carry" ]; then + h=$carry + counts[idx]=$h + fi + if [ "$h" -gt 0 ] && bashunit::coverage::_ends_with_continuation "${src[idx - 1]:-}"; then + carry=$h + else + carry=0 + fi + done + + local ln + for ((ln = 1; ln <= total; ln++)); do + [ "${counts[ln]:-0}" -gt 0 ] && echo "${ln}:${counts[ln]}" + done + + # The for loop's exit status leaks the last `[ -gt ]` test, which is 1 when the + # final line has no hits; return 0 explicitly so callers under `set -e` (strict + # mode) don't treat a successful run as a failure (see #722). + return 0 +} + + +# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by +# line number -> hit count) from get_all_line_hits for $1. +# +# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)" +# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an +# array from a function or pass one by reference, and copying a sparse array +# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would +# corrupt the line-number keys -- so this loader writes one shared global +# instead (return-slot pattern, see bash-style.md). Callers must consume the +# result before the next call; there is no adjacent-call isolation. +declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE + +function bashunit::coverage::load_hits_by_line() { + local file="$1" + _BASHUNIT_COVERAGE_HITS_BY_LINE=() + local hl_lineno hl_count + while IFS=: read -r hl_lineno hl_count; do + [ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count + done < <(bashunit::coverage::get_all_line_hits "$file") +} + +# Get all test hits for a file in one pass (performance optimization) +# Output format: lineno|test_file:test_function (may have duplicates, one per hit) +function bashunit::coverage::get_all_line_tests() { + local file="$1" + + if [ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]; then + return + fi + + # Format in file: source_file:line|test_file:test_function + # Output: lineno|test_file:test_function + grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | + sed "s|^${file}:||" | sort -u +} diff --git a/src/coverage/paths.sh b/src/coverage/paths.sh new file mode 100644 index 00000000..748c5f96 --- /dev/null +++ b/src/coverage/paths.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +# Which files coverage tracks, and the hot-path caches behind that decision. + +# In-memory caches for hot-path lookups (avoids grep + subshells) +_BASHUNIT_COVERAGE_TRACK_CACHE="" +_BASHUNIT_COVERAGE_PATH_CACHE="" + +# Normalize file path to absolute +function bashunit::coverage::normalize_path() { + local file="$1" + + # Normalize path to absolute + if [ -f "$file" ]; then + echo "$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" + else + echo "$file" + fi +} + +# Get deduplicated list of tracked files +function bashunit::coverage::get_tracked_files() { + if [ ! -f "$_BASHUNIT_COVERAGE_TRACKED_FILES" ]; then + return + fi + sort -u "$_BASHUNIT_COVERAGE_TRACKED_FILES" +} + +function bashunit::coverage::should_track() { + local file="$1" + + # Skip empty paths + [ -z "$file" ] && return 1 + + # Skip if tracked files list doesn't exist (trap inherited by child process) + [ -z "$_BASHUNIT_COVERAGE_TRACKED_FILES" ] && return 1 + + # Check file-based cache for previous decision (Bash 3.0 compatible) + # Cache format: "file:0" for excluded, "file:1" for tracked + # In parallel mode, use per-process cache to avoid race conditions + local cache_file="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + if bashunit::parallel::is_enabled && [ -n "$cache_file" ]; then + cache_file="${cache_file}.$$" + # Initialize per-process cache if needed + [ ! -f "$cache_file" ] && [ -d "$(dirname "$cache_file")" ] && : >"$cache_file" + fi + if [ -n "$cache_file" ] && [ -f "$cache_file" ]; then + local cached_decision + # Use || true to prevent exit in strict mode when grep finds no match + cached_decision=$(grep "^${file}:" "$cache_file" 2>/dev/null | head -1) || true + if [ -n "$cached_decision" ]; then + [ "${cached_decision##*:}" = "1" ] && return 0 || return 1 + fi + fi + + # Normalize path + local normalized_file + normalized_file=$(bashunit::coverage::normalize_path "$file") + + # Check exclusion patterns + # Save and restore IFS to avoid corrupting caller's environment + local old_ifs="$IFS" + IFS=',' + local pattern + for pattern in $BASHUNIT_COVERAGE_EXCLUDE; do + # shellcheck disable=SC2254 + case "$normalized_file" in + *$pattern*) + IFS="$old_ifs" + # Cache exclusion decision (use per-process cache in parallel mode) + { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:0" >>"$cache_file" + return 1 + ;; + esac + done + + # Check inclusion paths + local matched=false + local path + for path in $BASHUNIT_COVERAGE_PATHS; do + # Resolve relative paths + local resolved_path + case "$path" in + /*) + resolved_path="$path" + ;; + *) + resolved_path="$(pwd)/$path" + ;; + esac + + case "$normalized_file" in + "$resolved_path"*) + matched=true + break + ;; + esac + done + IFS="$old_ifs" + + if [ "$matched" = "false" ]; then + # Cache exclusion decision (use per-process cache in parallel mode) + { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:0" >>"$cache_file" + return 1 + fi + + # Cache tracking decision (use per-process cache in parallel mode) + { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:1" >>"$cache_file" + + # Track this file for later reporting + # In parallel mode, use a per-process file to avoid race conditions + local tracked_file="$_BASHUNIT_COVERAGE_TRACKED_FILES" + if bashunit::parallel::is_enabled; then + tracked_file="${_BASHUNIT_COVERAGE_TRACKED_FILES}.$$" + fi + + # Only write if parent directory exists + if [ -d "$(dirname "$tracked_file")" ]; then + # Check if not already written to avoid duplicates + if ! grep -q "^${normalized_file}$" "$tracked_file" 2>/dev/null; then + echo "$normalized_file" >>"$tracked_file" + fi + fi + + return 0 +} + +# Convert file path to safe filename for HTML +function bashunit::coverage::path_to_filename() { + local file="$1" + local display_file="${file#"$(pwd)"/}" + # Replace / with _ and . with _ + local safe_name="${display_file//\//_}" + echo "${safe_name//./_}" +} diff --git a/src/coverage/report_html.sh b/src/coverage/report_html.sh new file mode 100644 index 00000000..855338e3 --- /dev/null +++ b/src/coverage/report_html.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# HTML coverage report: orchestration and shared helpers. + +# Escape HTML special characters +# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings) +function bashunit::coverage::html_escape() { + local text="$1" + printf "%s" "$text" | sed "s/&/\&/g; s//\>/g" +} + +function bashunit::coverage::report_html() { + local output_dir="${1:-coverage/html}" + + if [ -z "$output_dir" ]; then + return 0 + fi + + # Create output directory structure + mkdir -p "$output_dir/files" + + # Collect file data for index + local IFS=$' \t\n' + local total_executable=0 + local total_hit=0 + local -a file_data=() + local file_data_count=0 + local file="" + + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + local stats executable hit pct + stats=$(bashunit::coverage::get_cached_stats "$file") + executable="${stats%%:*}" + stats="${stats#*:}" + hit="${stats%%:*}" + stats="${stats#*:}" + pct="${stats%%:*}" + + total_executable=$((total_executable + executable)) + total_hit=$((total_hit + hit)) + + local display_file="${file#"$(pwd)"/}" + local safe_filename + safe_filename=$(bashunit::coverage::path_to_filename "$file") + + file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename" + file_data_count=$((file_data_count + 1)) + + # Generate individual file HTML + bashunit::coverage::generate_file_html "$file" "$output_dir/files/${safe_filename}.html" + done < <(bashunit::coverage::get_tracked_files) + + # Calculate total percentage + local total_pct + total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") + + # Get test results + local tests_passed tests_failed tests_total + tests_passed=$(bashunit::state::get_tests_passed) + tests_failed=$(bashunit::state::get_tests_failed) + tests_total=$((tests_passed + tests_failed)) + + # Generate index.html + bashunit::coverage::generate_index_html \ + "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" \ + "$tests_total" "$tests_passed" "$tests_failed" ${file_data[@]+"${file_data[@]}"} + + echo "Coverage HTML report written to: $output_dir/index.html" +} diff --git a/src/coverage/report_lcov.sh b/src/coverage/report_lcov.sh new file mode 100644 index 00000000..b1492669 --- /dev/null +++ b/src/coverage/report_lcov.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# LCOV coverage report. + +function bashunit::coverage::report_lcov() { + local output_file="${1:-$BASHUNIT_COVERAGE_REPORT}" + + if [ -z "$output_file" ]; then + return 0 + fi + + # Create output directory if needed + mkdir -p "$(dirname "$output_file")" + + # Generate LCOV format + { + echo "TN:" + + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + echo "SF:$file" + + bashunit::coverage::load_hits_by_line "$file" + + # Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk + # and buffer the matching FNDA lines for emission after, per + # LCOV convention. + local fn_total=0 fn_hit=0 fn_name fn_start fn_end fln any_hit + local -a fn_dn_records=() + local _fdi=0 + while IFS='|' read -r fn_name fn_start fn_end; do + [ -z "$fn_name" ] && continue + echo "FN:${fn_start},${fn_name}" + fn_total=$((fn_total + 1)) + + any_hit=0 + for ((fln = fn_start; fln <= fn_end; fln++)); do + if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then + any_hit=1 + break + fi + done + fn_dn_records[_fdi]="FNDA:${any_hit},${fn_name}" + _fdi=$((_fdi + 1)) + [ "$any_hit" -eq 1 ] && fn_hit=$((fn_hit + 1)) + done < <(bashunit::coverage::extract_functions "$file") + + local fda + for fda in ${fn_dn_records[@]+"${fn_dn_records[@]}"}; do + echo "$fda" + done + echo "FNF:$fn_total" + echo "FNH:$fn_hit" + + # Branch records (BRDA/BRF/BRH) + local br_total=0 br_hit=0 br_line br_block br_idx br_taken + while IFS='|' read -r br_line br_block br_idx br_taken; do + [ -z "$br_line" ] && continue + echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" + br_total=$((br_total + 1)) + [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) + done < <(bashunit::coverage::compute_branch_hits "$file") + echo "BRF:$br_total" + echo "BRH:$br_hit" + + local lineno=0 executable=0 hit=0 line + 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="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" + [ "$lh" -gt 0 ] && ((++hit)) + echo "DA:${lineno},${lh}" + done + + echo "LF:$executable" + echo "LH:$hit" + echo "end_of_record" + done < <(bashunit::coverage::get_tracked_files) + } >"$output_file" +} diff --git a/src/coverage/report_text.sh b/src/coverage/report_text.sh new file mode 100644 index 00000000..86c630b2 --- /dev/null +++ b/src/coverage/report_text.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash + +# Terminal coverage report. + +function bashunit::coverage::report_text() { + if ! bashunit::env::is_coverage_enabled; then + return 0 + fi + + local total_executable=0 + local total_hit=0 + local has_files=false + + echo "" + echo "Coverage Report" + echo "---------------" + + local file + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + has_files=true + + 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#*:}" + + total_executable=$((total_executable + executable)) + total_hit=$((total_hit + hit)) + + local color reset="$_BASHUNIT_COLOR_DEFAULT" + color=$(bashunit::coverage::get_color_for_class "$class") + + # Display relative path + local display_file="${file#"$(pwd)"/}" + printf "%s%-40s %3d/%3d lines (%3d%%)%s\n" \ + "$color" "$display_file" "$hit" "$executable" "$pct" "$reset" + done < <(bashunit::coverage::get_tracked_files) + + if [ "$has_files" != "true" ]; then + echo "---------------" + echo "Total: 0/0 (0%)" + return 0 + fi + + echo "---------------" + + # Total + local total_pct total_class + total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") + total_class=$(bashunit::coverage::get_coverage_class "$total_pct") + + local color reset="$_BASHUNIT_COLOR_DEFAULT" + color=$(bashunit::coverage::get_color_for_class "$total_class") + + printf "%sTotal: %d/%d (%d%%)%s\n" \ + "$color" "$total_hit" "$total_executable" "$total_pct" "$reset" + + # Optional per-function summary (gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS) + if [ "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:-false}" = "true" ]; then + bashunit::coverage::report_text_functions + fi + + # Optional uncovered hotspots (gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED) + if [ "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:-false}" = "true" ]; then + bashunit::coverage::report_text_uncovered + fi + + # Optional per-line execution counts (gated on BASHUNIT_COVERAGE_SHOW_LINE_HITS) + if [ "${BASHUNIT_COVERAGE_SHOW_LINE_HITS:-false}" = "true" ]; then + bashunit::coverage::report_text_line_hits + fi + + # Show report location if generated + if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then + echo "" + echo "Coverage report written to: $BASHUNIT_COVERAGE_REPORT" + fi +} + + +# Compress a sorted list of integers into a comma-separated range +# string (e.g. "3 4 5 7 9 10" -> "3-5,7,9-10"). Result on +# _BASHUNIT_RANGES_OUT to avoid a subshell on each call. +_BASHUNIT_RANGES_OUT="" + +function bashunit::coverage::_compress_ranges() { + local out="" start="" end="" n + for n in "$@"; do + if [ -z "$start" ]; then + start="$n" + end="$n" + elif [ "$n" -eq $((end + 1)) ]; then + end="$n" + else + if [ "$start" = "$end" ]; then + out="${out}${start}," + else + out="${out}${start}-${end}," + fi + start="$n" + end="$n" + fi + done + if [ -n "$start" ]; then + if [ "$start" = "$end" ]; then + out="${out}${start}" + else + out="${out}${start}-${end}" + fi + fi + _BASHUNIT_RANGES_OUT="${out%,}" +} + +# List executable lines that were never hit, grouped by file. +# Gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Output is suppressed +# when no uncovered lines exist so a fully-covered run stays quiet. +function bashunit::coverage::report_text_uncovered() { + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + bashunit::coverage::load_hits_by_line "$file" + + local -a uncovered_lines=() + local _ucount=0 + local lineno=0 line + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + bashunit::coverage::is_executable_line "$line" "$lineno" || continue + local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" + if [ "$lh" -eq 0 ]; then + uncovered_lines[_ucount]="$lineno" + _ucount=$((_ucount + 1)) + fi + done <"$file" + + [ "$_ucount" -eq 0 ] && continue + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Uncovered Lines" + echo "---------------" + printed_header=true + fi + + local display_file="${file#"$(pwd)"/}" + local color="$_BASHUNIT_COLOR_FAILED" reset="$_BASHUNIT_COLOR_DEFAULT" + local out + bashunit::coverage::_compress_ranges "${uncovered_lines[@]}" + out="$_BASHUNIT_RANGES_OUT" + + printf "%s%s:%s%s\n" "$color" "$display_file" "$out" "$reset" + done < <(bashunit::coverage::get_tracked_files) +} + +# Per-line execution hit counts, gated on BASHUNIT_COVERAGE_SHOW_LINE_HITS=true. +# Lists each covered executable line as ":", where count is the +# number of times the line ran (the same value LCOV emits in its DA records). +function bashunit::coverage::report_text_line_hits() { + local IFS=$' \t\n' + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + bashunit::coverage::load_hits_by_line "$file" + + local -a hit_specs=() + local _hc=0 + local lineno=0 line + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + bashunit::coverage::is_executable_line "$line" "$lineno" || continue + local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" + if [ "$lh" -gt 0 ]; then + hit_specs[_hc]="${lineno}:${lh}" + _hc=$((_hc + 1)) + fi + done <"$file" + + [ "$_hc" -eq 0 ] && continue + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Line Hits" + echo "---------" + printed_header=true + fi + + local display_file="${file#"$(pwd)"/}" + printf "%s: %s\n" "$display_file" "${hit_specs[*]}" + done < <(bashunit::coverage::get_tracked_files) +} + +# Per-function coverage summary printed after the file table. +# Gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep default output compact. +function bashunit::coverage::report_text_functions() { + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + local functions_data + functions_data=$(bashunit::coverage::extract_functions "$file") + [ -z "$functions_data" ] && continue + + bashunit::coverage::load_hits_by_line "$file" + + local -a file_lines=() + local _fli=0 _fl + while IFS= read -r _fl || [ -n "$_fl" ]; do + file_lines[_fli]="$_fl" + ((++_fli)) + done <"$file" + + local display_file="${file#"$(pwd)"/}" + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Functions" + echo "---------" + printed_header=true + fi + echo "${display_file}" + + local fn_name fn_start fn_end ln fn_executable fn_hit + local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" + while IFS='|' read -r fn_name fn_start fn_end; do + [ -z "$fn_name" ] && continue + + fn_executable=0 + fn_hit=0 + for ((ln = fn_start; ln <= fn_end; ln++)); do + bashunit::coverage::is_executable_line \ + "${file_lines[$((ln - 1))]:-}" "$ln" || continue + fn_executable=$((fn_executable + 1)) + [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1)) + done + + fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") + fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") + color=$(bashunit::coverage::get_color_for_class "$fn_class") + + printf " %s%-38s %3d/%3d lines (%3d%%)%s\n" \ + "$color" "$fn_name" "$fn_hit" "$fn_executable" "$fn_pct" "$reset" + done <<<"$functions_data" + done < <(bashunit::coverage::get_tracked_files) +} diff --git a/src/coverage/stats.sh b/src/coverage/stats.sh new file mode 100644 index 00000000..92596d0c --- /dev/null +++ b/src/coverage/stats.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash + +# Coverage percentages, the precomputed per-file stats cache and the threshold gate. + +# Get coverage class (high/medium/low) based on percentage +function bashunit::coverage::get_coverage_class() { + local pct="$1" + if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then + echo "high" + elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then + echo "medium" + else + echo "low" + fi +} + +function bashunit::coverage::get_color_for_class() { + case "$1" in + high) printf '%s' "$_BASHUNIT_COLOR_PASSED" ;; + medium) printf '%s' "$_BASHUNIT_COLOR_SKIPPED" ;; + low) printf '%s' "$_BASHUNIT_COLOR_FAILED" ;; + esac +} + +# Calculate percentage from hit and executable counts +function bashunit::coverage::calculate_percentage() { + local hit="$1" + local executable="$2" + if [ "$executable" -gt 0 ]; then + echo $((hit * 100 / executable)) + else + echo "0" + fi +} + +# Get file coverage stats as "executable:hit:pct:class" +function bashunit::coverage::get_file_stats() { + local file="$1" + 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") + 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::get_percentage() { + local total_executable=0 + local total_hit=0 + + 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") + + 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" +} + +function bashunit::coverage::check_threshold() { + if [ -z "$BASHUNIT_COVERAGE_MIN" ]; then + return 0 + fi + + local pct + pct=$(bashunit::coverage::get_percentage) + + if [ "$pct" -lt "$BASHUNIT_COVERAGE_MIN" ]; then + printf "%sCoverage %d%% is below minimum %d%%%s\n" \ + "$_BASHUNIT_COLOR_FAILED" "$pct" "$BASHUNIT_COVERAGE_MIN" "$_BASHUNIT_COLOR_DEFAULT" + return 1 + fi + + return 0 +}