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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

# plain 2 executable / 2 hit, cont 3/2, cold 1/0 -> 4 of 6.
assert_same "66" "$(bashunit::coverage::get_percentage)"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
- Performance: the DEBUG trap rejects a line from an untracked file before calling the recorder β€” a run matching no coverage path went from 2609ms to 497ms, against a 480ms no-coverage baseline (#1060)
- Performance: the LCOV emitter classifies and writes each file in one awk pass instead of a Bash loop per line β€” 8520ms to 6632ms for 40 files of 752 lines. The awk rules are diffed against the Bash reference line by line over every shell file in the repo (#1059)
- Performance: function declarations are scanned in one awk pass instead of a Bash loop counting braces with pattern substitution β€” 2238ms to 399ms for 128 files, and a `--coverage` run over `src` from 9.23s to 6.81s (#1084)
- Performance: every tracked file's line stats are computed by one awk invocation for the whole run instead of a Bash loop and three subshells per file β€” 2585ms to 153ms for 128 files, taking that `--coverage` run to 3.77s (#1088)

### Fixed
- Coverage reports every file under `--coverage-paths`, not only the ones a test executed: an untouched file shows as `0/N (0%)` and `--coverage-min` gates on that denominator. This repo reported 11 of its own 121 files. **Percentages drop, because the old ones were measured over the files that ran** (#1053)
Expand Down
88 changes: 79 additions & 9 deletions src/coverage/rules_awk.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,16 +92,10 @@ function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest,

return 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# Whether a source line ends with a line continuation: an odd number of
# trailing backslashes, and not a comment. Lives here because both the LCOV
# emitter and the stats pass propagate hits along a continuation chain (#722).
function bu_ends_with_continuation(line, lead, i, n) {
lead = line
sub(/^[ \t]+/, "", lead)
Expand All@@ -112,7 +106,16 @@ function bu_ends_with_continuation(line, lead, i, n) {
}
return (n % 2) == 1
}
'

# The DA/LF/LH block of one file's LCOV record, in one pass.
#
# Reads the file's aggregated hit block first (#1057), then the source, and
# applies the same continuation propagation the Bash reader does: the DEBUG
# trap attributes a multi-line statement to its starting line, so the count
# carries forward across the backslash chain (#722).
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_LCOV='
# The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits
# passes an EMPTY first file, and `FNR == NR` is then true for the first record
# of the SECOND file, which would swallow the source line 1.
Expand DownExpand Up@@ -149,6 +152,62 @@ END {
}
'

# Executable and hit counts for MANY files, in one awk invocation.
#
# The report needs a count per tracked file, and computing it per file meant a
# Bash loop over every line of every file: 1956ms for 128 files, the last
# per-line Bash loop in the report phase. Reading the manifest and walking each
# pair with getline pays the cost of a fork once for the whole run (#1088).
#
# Input is a manifest of "<hits block>\t<source>" lines; output is
# "<executable>\t<hit>\t<source>". The source path comes last so a path holding
# a tab still reads back whole.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_STATS='
{
hitsfile = $0
sub(/\t.*$/, "", hitsfile)
src = $0
sub(/^[^\t]*\t/, "", src)

split("", hits)
if (hitsfile != "") {
while ((getline hline < hitsfile) > 0) {
split(hline, hp, " ")
hits[hp[1] + 0] = hp[2] + 0
}
close(hitsfile)
}

total = 0
split("", sl)
while ((getline sline < src) > 0) {
total++
sl[total] = sline
}
close(src)

# The DEBUG trap attributes a multi-line statement to its starting line, so
# the count carries forward across the backslash chain (#722).
carry = 0
for (ln = 1; ln <= total; ln++) {
h = (ln in hits) ? hits[ln] : 0
if (carry > 0 && h < carry) { h = carry; hits[ln] = h }
if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 }
}

executable = 0
hit = 0
for (ln = 1; ln <= total; ln++) {
if (!bu_is_executable(sl[ln])) { continue }
executable++
if ((ln in hits) && hits[ln] > 0) { hit++ }
}

print executable "\t" hit "\t" src
}
'

##
# The awk source of the shared classification rules.
##
Expand All@@ -175,3 +234,14 @@ function bashunit::coverage::awk_lcov_lines() {
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \
"$hits_file" "$file"
}

##
# Emits "<executable>\t<hit>\t<source>" for every pair in the manifest, in one
# awk invocation.
# Arguments: $1 - manifest of "<hits block>\t<source>" lines
##
function bashunit::coverage::awk_file_stats() {
env LC_ALL=C "$AWK" \
"${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \
"$1"
}
96 changes: 81 additions & 15 deletions src/coverage/stats.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,31 @@ function bashunit::coverage::_compute_file_stats() {
local file="$1"
local stats
stats=$(bashunit::coverage::compute_file_coverage "$file")
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="${stats%%:*}"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="${stats##*:}"
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT=$(bashunit::coverage::calculate_percentage \
"$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT")
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT=$(bashunit::coverage::get_coverage_class \
"$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT")
bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}"
}

# Fills the four slots from an executable/hit pair. Split out so the batch pass
# and the per-file path derive pct and class the same way, and so neither forks
# for them: percentage and class each used to cost a subshell per file, which
# at 128 tracked files was more than the arithmetic they wrapped (#1088).
function bashunit::coverage::_derive_file_stats() {
local executable="$1" hit="$2"
_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable"
_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit"

local pct=0
if [ "$executable" -gt 0 ]; then
pct=$((hit * 100 / executable))
fi
_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct"

if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="medium"
else
_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="low"
fi
}

# Get file coverage stats as "executable:hit:pct:class"
Expand DownExpand Up@@ -86,23 +105,70 @@ function bashunit::coverage::precompute_file_stats() {
_BASHUNIT_COVERAGE_STATS_COUNT=0
bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_"

if bashunit::coverage::_precompute_batch; then
return 0
fi

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

bashunit::coverage::_compute_file_stats "$file"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
bashunit::coverage::_record_file_stats "$file" \
"$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
done < <(bashunit::coverage::get_tracked_files)
}

# Appends one file to the stats cache.
function bashunit::coverage::_record_file_stats() {
local file="$1"
bashunit::coverage::_derive_file_stats "$2" "$3"

local idx="$_BASHUNIT_COVERAGE_STATS_COUNT"
_BASHUNIT_COVERAGE_STATS_FILES[idx]="$file"
_BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT"
_BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT"
_BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT"
_BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT"
_BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1))
bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx"
}

# Fills the whole cache with one awk invocation, and reports whether it could.
#
# Returns 1 without touching the cache when there is nowhere to write the
# manifest or the pass produced nothing for a non-empty tracked list, so the
# caller falls back to the per-file path and a report is never silently empty.
function bashunit::coverage::_precompute_batch() {
local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}"
{ [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1

bashunit::coverage::ensure_hits_aggregated

local manifest="$data_dir/stats-manifest"
local tracked=0 file
{
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue
tracked=$((tracked + 1))
bashunit::coverage::hits_file_for "$file"
printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file"
done < <(bashunit::coverage::get_tracked_files)
} >"$manifest" 2>/dev/null || return 1

if [ "$tracked" -eq 0 ]; then
return 0
fi

local executable hit
while IFS="$(printf '\t')" read -r executable hit file; do
[ -n "$file" ] || continue
bashunit::coverage::_record_file_stats "$file" "$executable" "$hit"
done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null)

[ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]
}

# Look up cached stats for a file, returns "executable:hit:pct:class"
function bashunit::coverage::get_cached_stats() {
local file="$1"
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/coverage/precompute_stats_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

# precompute_file_stats fills the cache every renderer reads, and get_file_stats
# computes one file on demand. They must agree exactly: the cache is what the
# report shows, and a file the cache missed falls through to the on-demand path
# mid-report. This pins the two together so the batch path cannot drift (#1088).

function set_up() {
WORK="$(bashunit::temp_dir)/precompute"
mkdir -p "$WORK"
# The tracked list collapses a doubled slash and the hit blocks are named
# after the recorded path, so the fixture has to record the same spelling the
# tracked list holds -- `/tmp//bashunit` would key its blocks differently and
# read back as zero hits.
local slash="/"
WORK="${WORK//\/\//$slash}"

# Plain: three executable lines, one comment, one blank.
printf 'function plain() {\n local a=1\n\n # note\n echo "$a"\n}\n' >"$WORK/plain.sh"
# A statement continued over two lines: the hit on the first must carry to
# the second, which is the rule the batch pass has to reproduce (#722).
printf 'function cont() {\n echo "one" \\\n "two"\n echo "three"\n}\n' >"$WORK/cont.sh"
# Never executed at all.
printf 'function cold() {\n echo "never"\n}\n' >"$WORK/cold.sh"

# The paths have to be in place before init: it is what decides the tracked
# set the report is about.
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_PATHS="$WORK"
# shellcheck disable=SC2034 # read by coverage::init and the seeding
BASHUNIT_COVERAGE_EXCLUDE=""
# shellcheck disable=SC2034 # read by coverage::init
BASHUNIT_COVERAGE="true"
bashunit::coverage::init

{
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:2"
echo "$WORK/plain.sh:5"
echo "$WORK/cont.sh:2"
} >>"$_BASHUNIT_COVERAGE_DATA_FILE"
bashunit::coverage::invalidate_hits_aggregation
}

function test_the_batch_pass_matches_the_per_file_path_for_every_file() {
bashunit::coverage::precompute_file_stats

local file
for file in "$WORK/plain.sh" "$WORK/cont.sh" "$WORK/cold.sh"; do
assert_same "$(bashunit::coverage::get_file_stats "$file")" \
"$(bashunit::coverage::get_cached_stats "$file")"
done
}

function test_the_batch_pass_carries_a_hit_across_a_line_continuation() {
bashunit::coverage::precompute_file_stats

# `echo "one" \` runs and its continuation counts as run with it, so 2 of the
# 3 executable lines are hit -- the trailing `echo "three"` never ran.
assert_same "3:2:66:medium" "$(bashunit::coverage::get_cached_stats "$WORK/cont.sh")"
}

function test_a_file_no_test_executed_counts_with_zero_hits() {
bashunit::coverage::precompute_file_stats

assert_same "1:0:0:low" "$(bashunit::coverage::get_cached_stats "$WORK/cold.sh")"
}

function test_the_total_percentage_covers_every_tracked_file() {
bashunit::coverage::precompute_file_stats

# plain 2 executable / 2 hit, cont 3/2, cold 1/0 -> 4 of 6.
assert_same "66" "$(bashunit::coverage::get_percentage)"
}
Loading