diff --git a/CHANGELOG.md b/CHANGELOG.md index 3680f491..3b84064f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/coverage/functions.sh b/src/coverage/functions.sh index 7a415ee2..ce06230d 100644 --- a/src/coverage/functions.sh +++ b/src/coverage/functions.sh @@ -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 @@ -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) { @@ -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 diff --git a/tests/unit/coverage/helpers_test.sh b/tests/unit/coverage/helpers_test.sh index b01e8e1b..44dc4ed6 100644 --- a/tests/unit/coverage/helpers_test.sh +++ b/tests/unit/coverage/helpers_test.sh @@ -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() {