Skip to content

Commit 2eb39cf

Browse files
fix(coverage): count backslash line-continuation lines as covered (#723)
Co-authored-by: Chemaclass <chemaclass@outlook.es>
1 parent b56cd93 commit 2eb39cf

3 files changed

Lines changed: 167 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
### Fixed
6+
- Coverage report now counts backslash line-continuation lines as covered: a multi-line statement's hit is propagated forward across its continuation chain, so the lines after a trailing `\` are no longer reported as uncovered (#722)
7+
58
### Changed
69
- Documentation and project URLs now point to the new primary domain `bashunit.com` (old `bashunit.typeddevs.com` continues to work as a redirect)
710
- The docs site now deploys to GitHub Pages on the `bashunit.com` custom domain (`deploy-gh-pages.yml`); the installer is copied into the site root from the repo's canonical `install.sh`

src/coverage.sh

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -647,22 +647,84 @@ function bashunit::coverage::compute_file_coverage() {
647647
echo "${executable}:${hit}"
648648
}
649649

650+
# Detect whether a source line ends with a Bash line-continuation, i.e. an
651+
# odd number of unescaped trailing backslashes with no trailing whitespace.
652+
# Comment lines never continue. Used to propagate coverage hits from a
653+
# statement's starting line to its continuation lines (see #722).
654+
function bashunit::coverage::_ends_with_continuation() {
655+
local line="$1"
656+
local lead="${line#"${line%%[![:space:]]*}"}"
657+
case "$lead" in '#'*) return 1 ;; esac
658+
local trailing="${line##*[!\\]}"
659+
case "$line" in *[!\\]*) : ;; *) trailing="$line" ;; esac
660+
[ $((${#trailing} % 2)) -eq 1 ]
661+
}
662+
650663
# Get all line hits for a file in one pass (performance optimization)
651664
# Output format: one "lineno:count" per line
665+
#
666+
# Bash's DEBUG trap attributes a multi-line statement's execution to the line
667+
# where the statement starts; backslash continuation lines never receive their
668+
# own hit. To match the report's expectation that continuation lines are
669+
# covered, the start line's count is propagated forward across the
670+
# continuation chain (see #722).
652671
function bashunit::coverage::get_all_line_hits() {
653672
local file="$1"
654673

655674
if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then
656675
return
657676
fi
658677

659-
# Extract all lines for this file, count occurrences of each line number
660-
local count lineno
661-
grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null |
662-
cut -d: -f2 | sort | uniq -c |
663-
while read -r count lineno; do
664-
echo "${lineno}:${count}"
665-
done
678+
# Extract all lines for this file, count occurrences of each line number.
679+
local -a counts=()
680+
local count lineno maxln=0
681+
while read -r count lineno; do
682+
if [ -n "$lineno" ]; then
683+
counts[lineno]=$count
684+
[ "$lineno" -gt "$maxln" ] && maxln=$lineno
685+
fi
686+
done < <(grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null |
687+
cut -d: -f2 | sort | uniq -c)
688+
689+
if [ "$maxln" -eq 0 ]; then
690+
return
691+
fi
692+
693+
# Read the source so continuation lines can be detected.
694+
local -a src=()
695+
local _i=0 _l
696+
while IFS= read -r _l || [ -n "$_l" ]; do
697+
src[_i]="$_l"
698+
((++_i))
699+
done <"$file"
700+
701+
local total=$_i
702+
[ "$maxln" -gt "$total" ] && total=$maxln
703+
704+
# Propagate each start line's count forward across its continuation chain.
705+
local carry=0 idx h
706+
for ((idx = 1; idx <= total; idx++)); do
707+
h=${counts[idx]:-0}
708+
if [ "$carry" -gt 0 ] && [ "$h" -lt "$carry" ]; then
709+
h=$carry
710+
counts[idx]=$h
711+
fi
712+
if [ "$h" -gt 0 ] && bashunit::coverage::_ends_with_continuation "${src[idx - 1]:-}"; then
713+
carry=$h
714+
else
715+
carry=0
716+
fi
717+
done
718+
719+
local ln
720+
for ((ln = 1; ln <= total; ln++)); do
721+
[ "${counts[ln]:-0}" -gt 0 ] && echo "${ln}:${counts[ln]}"
722+
done
723+
724+
# The for loop's exit status leaks the last `[ -gt ]` test, which is 1 when the
725+
# final line has no hits; return 0 explicitly so callers under `set -e` (strict
726+
# mode) don't treat a successful run as a failure (see #722).
727+
return 0
666728
}
667729

668730
# Get all test hits for a file in one pass (performance optimization)

tests/unit/coverage_executable_test.sh

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env bash
22
# shellcheck disable=SC2317
3+
# shellcheck disable=SC1003 # intentional literal trailing backslashes in test inputs
34

45
# Save original coverage state to restore after tests
56
_ORIG_COVERAGE_DATA_FILE=""
@@ -283,3 +284,97 @@ function test_coverage_is_executable_line_returns_false_for_done_with_append_red
283284
result=$(bashunit::coverage::is_executable_line ' done >> /tmp/out.log' 2 && echo "yes" || echo "no")
284285
assert_equals "no" "$result"
285286
}
287+
288+
# --- _ends_with_continuation (#722) -----------------------------------------
289+
290+
function test_coverage_ends_with_continuation_true_for_single_trailing_backslash() {
291+
local result
292+
result=$(bashunit::coverage::_ends_with_continuation 'echo foo \' && echo "yes" || echo "no")
293+
assert_equals "yes" "$result"
294+
}
295+
296+
function test_coverage_ends_with_continuation_true_for_indented_continuation() {
297+
local result
298+
result=$(bashunit::coverage::_ends_with_continuation ' some_command --flag \' && echo "yes" || echo "no")
299+
assert_equals "yes" "$result"
300+
}
301+
302+
function test_coverage_ends_with_continuation_false_for_no_backslash() {
303+
local result
304+
result=$(bashunit::coverage::_ends_with_continuation 'echo foo' && echo "yes" || echo "no")
305+
assert_equals "no" "$result"
306+
}
307+
308+
function test_coverage_ends_with_continuation_false_for_escaped_backslash() {
309+
local result
310+
result=$(bashunit::coverage::_ends_with_continuation 'echo foo \\' && echo "yes" || echo "no")
311+
assert_equals "no" "$result"
312+
}
313+
314+
function test_coverage_ends_with_continuation_true_for_three_backslashes() {
315+
local result
316+
result=$(bashunit::coverage::_ends_with_continuation 'echo foo \\\' && echo "yes" || echo "no")
317+
assert_equals "yes" "$result"
318+
}
319+
320+
function test_coverage_ends_with_continuation_false_for_trailing_whitespace_after_backslash() {
321+
local result
322+
result=$(bashunit::coverage::_ends_with_continuation 'echo foo \ ' && echo "yes" || echo "no")
323+
assert_equals "no" "$result"
324+
}
325+
326+
function test_coverage_ends_with_continuation_false_for_comment_line() {
327+
local result
328+
result=$(bashunit::coverage::_ends_with_continuation '# a trailing slash in a comment \' && echo "yes" || echo "no")
329+
assert_equals "no" "$result"
330+
}
331+
332+
function test_coverage_ends_with_continuation_false_for_empty_line() {
333+
local result
334+
result=$(bashunit::coverage::_ends_with_continuation '' && echo "yes" || echo "no")
335+
assert_equals "no" "$result"
336+
}
337+
338+
# --- get_all_line_hits continuation propagation (#722) ----------------------
339+
340+
function test_coverage_get_all_line_hits_propagates_across_continuation_chain() {
341+
local dir
342+
dir=$(mktemp -d)
343+
_BASHUNIT_COVERAGE_DATA_FILE="${dir}/coverage.data"
344+
345+
local src="${dir}/script.sh"
346+
printf '%s\n' 'echo start \' ' middle \' ' end' 'echo other' >"$src"
347+
348+
# Start line (1) hit twice, standalone line (4) hit once.
349+
printf '%s\n' "${src}:1" "${src}:1" "${src}:4" >"$_BASHUNIT_COVERAGE_DATA_FILE"
350+
351+
local result
352+
result=$(bashunit::coverage::get_all_line_hits "$src")
353+
354+
local expected
355+
expected=$(printf '%s\n' "1:2" "2:2" "3:2" "4:1")
356+
357+
rm -rf "$dir" 2>/dev/null || true
358+
_BASHUNIT_COVERAGE_DATA_FILE=""
359+
360+
assert_equals "$expected" "$result"
361+
}
362+
363+
function test_coverage_get_all_line_hits_does_not_propagate_without_continuation() {
364+
local dir
365+
dir=$(mktemp -d)
366+
_BASHUNIT_COVERAGE_DATA_FILE="${dir}/coverage.data"
367+
368+
local src="${dir}/script.sh"
369+
printf '%s\n' 'echo one' 'echo two' >"$src"
370+
371+
printf '%s\n' "${src}:1" >"$_BASHUNIT_COVERAGE_DATA_FILE"
372+
373+
local result
374+
result=$(bashunit::coverage::get_all_line_hits "$src")
375+
376+
rm -rf "$dir" 2>/dev/null || true
377+
_BASHUNIT_COVERAGE_DATA_FILE=""
378+
379+
assert_equals "1:1" "$result"
380+
}

0 commit comments

Comments
 (0)