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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); perf(coverage): scan function declarations in one awk pass by Chemaclass · Pull Request #1085 · TypedDevs/bashunit · GitHub
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@@ -22,6 +22,7 @@
- Performance: hit data is grouped once per run by one awk pass, instead of `grep | cut | sort | uniq -c` per file — 1294ms to 203ms at 121 files and 10,890 records (#1057)
- 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)

### 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
182 changes: 89 additions & 93 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,105 +2,101 @@

# Locating function definitions and their line spans, for the reports.

# Extract function definitions from a bash file
# Output format: function_name:start_line:end_line (one per function)
function bashunit::coverage::extract_functions() {
local file="$1"

local lineno=0
local in_function=0
local brace_count=0
local current_fn=""
local fn_start=0
local line

while IFS= read -r line || [ -n "$line" ]; do
((++lineno))

# Check for function definition patterns
# The declaration scanner, as awk source.
#
# It was a Bash `while read` loop with two `${line//[^\{]/}` substitutions per
# line to count braces. Bash 3.2 pattern substitution over a whole file is the
# single most expensive thing in the report phase: 17.5 ms per file against
# awk's 3.1 ms, and the report calls this once per file per renderer. One pass
# in awk instead (#1084).
#
# The rules are unchanged, quirks included -- notably that braces are counted
# without regard for strings or comments, so `echo "{"` inside a body extends
# the span. Changing that is a numbers change, not a perf change.
#
# It lives in a shell string rather than a .awk file because the build flattens
# *.sh into one artifact (ADR-011); a separate file would not ship.
# shellcheck disable=SC2016 # the $0 in here is awk's, not the shell's
_BASHUNIT_COVERAGE_AWK_FUNCTIONS='
{
line = $0
if (in_function == 0) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
if [ "$in_function" -eq 0 ]; then
local fn_name=""

# Extract function name using pure Bash string operations (avoids sed subshell)
local stripped="${line#"${line%%[![:space:]]*}"}"

# Strip "function " prefix if present
case "$stripped" in
function[\ \ ]*)
stripped="${stripped#function}"
stripped="${stripped#"${stripped%%[![:space:]]*}"}"
;;
esac
stripped = line
sub(/^[ \t]+/, "", stripped)
if (stripped ~ /^function[ \t]/) {
sub(/^function/, "", stripped)
sub(/^[ \t]+/, "", stripped)
}

# Extract first word as candidate function name
fn_name="${stripped%%[[:space:]\(\{]*}"
# The candidate name is the first word, ending at whitespace, `(` or `{`.
name = stripped
sub(/[ \t({].*$/, "", name)

# Validate: must BE an identifier, and rest must have () or {
if [ -n "$fn_name" ]; then
case "$fn_name" in
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener — every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed report_lcov's
# arithmetic (#936). Checking only the first character let all of that
# through.
*[!a-zA-Z0-9_:]*) fn_name="" ;;
[a-zA-Z_]*)
local after_name="${stripped#"$fn_name"}"
after_name="${after_name#"${after_name%%[![:space:]]*}"}"
case "$after_name" in
'()'* | '{'*) ;;
*) fn_name="" ;;
esac
;;
*) fn_name="" ;;
esac
fi
if (name != "") {
ok = 1
# A candidate holding anything outside the identifier alphabet is not a
# function name. Cutting at the first `{` means `VAR="x${Y}"` yields
# `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every
# such assignment became a phantom FN record, and one containing the
# record separator `|` shifted the fields and crashed the arithmetic in
# report_lcov (#936). Checking only the first character let all of that
# through.
if (name ~ /[^a-zA-Z0-9_:]/) {
ok = 0
} else if (name !~ /^[a-zA-Z_]/) {
ok = 0
} else {
# A declaration continues with `()` or `{`; a call does not.
after = substr(stripped, length(name) + 1)
sub(/^[ \t]+/, "", after)
if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 }
}

if [ -n "$fn_name" ]; then
in_function=1
current_fn="$fn_name"
fn_start=$lineno
brace_count=0
if (ok) {
in_function = 1
current_fn = name
fn_start = NR
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = nopen - nclose
# Single-line function: braces balance on the same line, both present.
if (brace_count == 0 && nopen > 0 && nclose > 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
}
next
}
}
}

# Count opening braces on this line
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
local open_count=${#open_braces}
local close_count=${#close_braces}
brace_count=$((brace_count + open_count - close_count))

# Single-line function: braces balance on same line and both present
if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
fi
continue
fi
fi

# Track braces inside function
if [ "$in_function" -eq 1 ]; then
local open_braces="${line//[^\{]/}"
local close_braces="${line//[^\}]/}"
brace_count=$((brace_count + ${#open_braces} - ${#close_braces}))
if (in_function == 1) {
tmp = line; nopen = gsub(/\{/, "{", tmp)
tmp = line; nclose = gsub(/\}/, "}", tmp)
brace_count = brace_count + nopen - nclose
if (brace_count <= 0) {
print current_fn "|" fn_start "|" NR
in_function = 0
current_fn = ""
brace_count = 0
}
}
}

# Function ended
if [ "$brace_count" -le 0 ]; then
echo "${current_fn}|${fn_start}|${lineno}"
in_function=0
current_fn=""
brace_count=0
fi
fi
done <"$file"
# An unclosed function (should not happen in valid code) still gets a record,
# ending at the last line, so a truncated file cannot drop one silently.
END {
if (in_function == 1 && current_fn != "") { print current_fn "|" fn_start "|" NR }
}
'

# Handle unclosed function (shouldn't happen in valid code)
if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then
echo "${current_fn}|${fn_start}|${lineno}"
fi
##
# Extract function definitions from a bash file.
# Output format: function_name|start_line|end_line (one per function)
# Arguments: $1 - source file
##
function bashunit::coverage::extract_functions() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_FUNCTIONS" "$1"
}
123 changes: 123 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -265,6 +265,129 @@ EOF
rm -f "$temp_file"
}

# The span of a function is decided by brace balance, so a nested block inside
# the body must not close it early.
function test_coverage_extract_functions_spans_nested_braces() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function outer() {
local map="${x:-fallback}"
if [ -n "$map" ]; then
echo "deep"
fi
}
function after() {
echo "after"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "outer|2|7
after|8|10" "$result"

rm -f "$temp_file"
}

# A body that opens and closes on the declaration line is one record whose start
# and end are the same line.
function test_coverage_extract_functions_reports_a_single_line_function() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function one_liner() { echo "hi"; }
bare_liner() { echo "there"; }
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "one_liner|2|2
bare_liner|3|3" "$result"

rm -f "$temp_file"
}

# The three declaration spellings bash accepts, plus an indented one: `name()`,
# `function name()` and `function name` with no parentheses at all.
function test_coverage_extract_functions_accepts_every_declaration_spelling() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
bare() {
echo "a"
}
function keyword() {
echo "b"
}
function no_parens {
echo "c"
}
indented() {
echo "d"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "bare|2|4
keyword|5|7
no_parens|8|10
indented|11|13" "$result"

rm -f "$temp_file"
}

# An unbalanced file still reports the function, ending at the last line, so a
# truncated or generated file cannot drop a record silently.
function test_coverage_extract_functions_closes_an_unclosed_function_at_eof() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function unclosed() {
echo "no closing brace"
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "unclosed|2|3" "$result"

rm -f "$temp_file"
}

# A name is only a name inside [a-zA-Z0-9_:] and starting with a letter or `_`,
# and the declaration must continue with `()` or `{` -- a call is not a
# definition.
function test_coverage_extract_functions_rejects_non_declarations() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
9lives() {
echo "starts with a digit"
}
call_me arg
real_fn() {
echo "yes"
}
FIXTURE

local result
result=$(bashunit::coverage::extract_functions "$temp_file")

assert_same "real_fn|6|8" "$result"

rm -f "$temp_file"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading