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@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
- 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)
- `--coverage-diff` counts a changed file that no test executed, instead of skipping it and letting a brand new untested file pass a `--coverage-min 90` gate. A docs-only commit still reports 100% (#1054)
- Coverage read a statement ending in `)` as a `case` arm, so `x=$(foo)` left the denominator while `x=$(printf '%s\n')` stayed. A `)` now closes an arm only when no `(` opened earlier on the line, recovering 456 executable lines of this repo's `src/`. **Percentages move in both directions per file** (#1055)
- A brace inside a comment, a string or a heredoc no longer counts towards a function's span, so a single stray `{` stops swallowing every later function in the file β€” 11 functions in this repo's `src/coverage/lines.sh` were reported as 1, and `END {` inside an embedded awk program was reported as a function. `FN`, `FNDA`, `FNF` and `FNH` change; lines and branches do not (#1086)

## [0.46.0](https://github.com/TypedDevs/bashunit/compare/0.45.0...0.46.0) - 2026-08-11

Expand Down
91 changes: 83 additions & 8 deletions src/coverage/functions.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,96 @@
# 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.
# Braces are counted as code only: a brace inside a comment, a string or a
# heredoc body is data, and counting it kept the enclosing function open so it
# swallowed every later declaration in the file (#1086).
#
# 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='
BEGIN { SQ = sprintf("%c", 39) }

# Scans one line under the quote and heredoc state carried over from the lines
# before it -- a string or a heredoc body can span lines, so per-line state is
# not enough. Sets nopen/nclose to the braces that are code, and code_start to
# 1 when the line begins outside any string or heredoc, which is the only place
# a declaration can start.
function bu_scan(line, i, n, c, rest, delim, q) {
nopen = 0
nclose = 0
code_start = (in_s == 0 && in_d == 0 && hd == "")

if (hd != "") {
rest = line
if (hd_strip) { sub(/^\t+/, "", rest) }
if (rest == hd) { hd = "" }
return
}

n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)

if (in_s) {
# Single quotes take no escapes: the next one always closes.
if (c == SQ) { in_s = 0 }
continue
}
if (in_d) {
if (c == "\\") { i++; continue }
if (c == "\"") { in_d = 0 }
continue
}
if (c == "\\") { i++; continue }
if (c == SQ) { in_s = 1; continue }
if (c == "\"") { in_d = 1; continue }

# A `#` opens a comment only where bash opens one, at the start of a word,
# so ${x#foo} and a#b keep their braces.
if (c == "#") {
if (i == 1) { return }
q = substr(line, i - 1, 1)
if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return }
continue
}

if (c == "<" && substr(line, i + 1, 1) == "<") {
# `<<<` is a here-string: one line, no body. Consume all three so the
# second `<` cannot read as the start of a heredoc and swallow the file.
if (substr(line, i + 2, 1) == "<") { i = i + 2; continue }

rest = substr(line, i + 2)
hd_strip = 0
if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) }
sub(/^[ \t]+/, "", rest)
q = substr(rest, 1, 1)
if (q == SQ || q == "\"") {
delim = substr(rest, 2)
if (index(delim, q) == 0) {
delim = ""
} else {
sub(q ".*$", "", delim)
}
} else {
delim = rest
sub(/[ \t;)&|<>].*$/, "", delim)
}
# The body starts on the next line, so nothing after the operator on this
# one can close the function.
if (delim != "") { hd = delim; return }
continue
}

if (c == "{") { nopen++ } else if (c == "}") { nclose++ }
}
}

{
line = $0
if (in_function == 0) {
bu_scan(line)

if (in_function == 0 && code_start) {
# Pattern 1: function name() { or function name {
# Pattern 2: name() { or name () {
stripped = line
Expand DownExpand Up@@ -58,8 +137,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
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) {
Expand All@@ -73,8 +150,6 @@ _BASHUNIT_COVERAGE_AWK_FUNCTIONS='
}

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
Expand Down
179 changes: 179 additions & 0 deletions tests/unit/coverage/helpers_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,6 +388,185 @@ FIXTURE
rm -f "$temp_file"
}

# A function ends where its braces balance, so a brace that is not code at all
# must not count. One stray `{` in a comment or a string used to keep the
# function open and swallow every later declaration in the file: 11 functions
# in src/coverage/lines.sh collapsed into 1 (#1086).
function test_coverage_extract_functions_ignores_a_brace_in_a_comment() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
# a stray { in a comment
echo "one"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|5
second|6|8" "$result"

rm -f "$temp_file"
}

function test_coverage_extract_functions_ignores_a_brace_in_a_string() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function first() {
local open="{"
local close='}'
echo "$open$close"
}
function second() {
echo "two"
}
FIXTURE

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

assert_same "first|2|6
second|7|9" "$result"

rm -f "$temp_file"
}

# An embedded awk program is a single-quoted string spanning many lines. Its
# `END {` was read as a declaration of a function called END, and its braces
# were counted as if they belonged to the enclosing file.
function test_coverage_extract_functions_ignores_a_multi_line_quoted_program() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function after_program() {
echo "after"
}
FIXTURE

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

assert_same "after_program|6|8" "$result"

rm -f "$temp_file"
}

# A heredoc body is data, not code: neither its braces nor a line that looks
# like a declaration belong to the file being scanned.
function test_coverage_extract_functions_skips_a_heredoc_body() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function emits() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
function after_heredoc() {
echo "after"
}
FIXTURE

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

assert_same "emits|2|7
after_heredoc|8|10" "$result"

rm -f "$temp_file"
}

# `<<<` is a here-string, not a heredoc: it has no body to skip, so scanning
# must not swallow the rest of the file waiting for a terminator.
function test_coverage_extract_functions_treats_a_here_string_as_one_line() {
local temp_file
temp_file=$(mktemp)
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
function reads() {
local item
while IFS= read -r item; do
echo "$item"
done <<<"$list"
}
function after_here_string() {
echo "after"
}
FIXTURE

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

assert_same "reads|2|7
after_here_string|8|10" "$result"

rm -f "$temp_file"
}

# Bash itself is the oracle: under `extdebug`, `declare -F` reports the real
# start line of every function it sourced. Extraction has to agree with it on a
# file holding all of the shapes above. extdebug is enabled inside the
# subshell only -- in the caller it clobbers state the runner depends on (#808).
function test_coverage_extract_functions_agrees_with_bash_on_the_hard_shapes() {
local temp_file
temp_file="$(bashunit::temp_file extract_oracle).sh"
cat >"$temp_file" <<'FIXTURE'
#!/usr/bin/env bash
PROGRAM='
{ print "{" }
END { print "}" }
'
function first() {
# a stray { in a comment
local brace="{"
echo "$brace"
}
function second() {
cat <<'BODY'
function not_a_function() {
BODY
echo "done"
}
third() { echo "one line"; }
function fourth() {
local closing
closing=$(printf '%s' "}")
echo "$closing"
}
FIXTURE

local oracle
oracle=$(
shopt -s extdebug
# shellcheck source=/dev/null
source "$temp_file"
local fn
for fn in first second third fourth; do
declare -F "$fn"
done | awk '{ print $1 "|" $2 }'
)

local extracted
extracted=$(bashunit::coverage::extract_functions "$temp_file" | awk -F'|' '{ print $1 "|" $2 }')

assert_same "$oracle" "$extracted"
}

# === Line hits tests ===

function test_coverage_get_all_line_hits_counts_per_line() {
Expand Down
Loading