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 number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766)

### Changed
- `bashunit doc` no longer forks an `echo | sed` pipe per line of the assertion docs: a single awk pass prints the same bytes in ~50ms instead of ~5s (#832)
- Multi-file runs are no longer quadratic in file count: each file's test functions are unset once the file has been processed, so test subshells stop forking an ever-growing shell. bashunit's own 63-file unit suite: ~64s -> ~22s sequential, ~26s -> ~7s parallel (#829)
- Major performance work with no behaviour change: assertions, per-test execution, per-file discovery, cold start and parallel result publishing are now (near) fork-free, snapshots and `--tag` scans are cached, and quadratic failure rendering is single-pass. Benchmarks on bash 3.2: 100x10 `assert_equals` ~1.50s -> ~0.76s, 500 snapshot assertions ~7.5s -> ~3.0s, 100 tagged tests ~2.92s -> ~0.68s, and bashunit's own acceptance suite ~61s -> ~17s (#761-#764, #772-#775, #798, #801-#807, #809, #810, #813, #817)
- Per-test timing now defaults to `auto` (`BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`): shown only when the clock is fork-free, avoiding `perl` forks on bash 3.2; `--profile`/`--verbose`/reports still measure (see `adrs/adr-008-auto-skip-per-test-timing.md`) (#765)
Expand Down
89 changes: 40 additions & 49 deletions src/doc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,49 @@ function bashunit::doc::get_embedded_docs() {
# __BASHUNIT_EMBEDDED_DOCS_END__
}

# Single awk pass over the embedded docs: the previous line-by-line shell loop
# forked an `echo | sed` pipe per line (~3.2k forks, ~5s for the ~1.6k-line
# docs page); one awk fork does the same work in milliseconds (#832).
function bashunit::doc::print_asserts() {
local filter="${1:-}"
local docstring=""
local fn=""
local should_print=0

local line
while IFS='' read -r line || [ -n "$line" ]; do
fn=$(echo "$line" | sed -n 's/^## \([A-Za-z0-9_]*\).*/\1/p')
# Only treat assertion headings as doc entries; skip prose sections
# like "## Related" that are part of the documentation page but not asserts.
case "$fn" in
assert* | bashunit*) ;;
*) fn="" ;;
esac
if [ -n "$fn" ]; then
local _match=0
if [ -z "$filter" ]; then
_match=1
else
case "$fn" in *"$filter"*) _match=1 ;; esac
fi
if [ "$_match" -eq 1 ]; then
should_print=1
echo "$line"
docstring=""
else
should_print=0
fi
continue
fi
bashunit::doc::get_embedded_docs | awk -v filter="$filter" '
{
if ($0 ~ /^## /) {
# Heading word: the leading [A-Za-z0-9_]* run after "## ". Only
# assert*/bashunit* headings are doc entries; prose headings like
# "## Related" fall through and are treated as regular content.
fn = substr($0, 4)
sub(/[^A-Za-z0-9_].*$/, "", fn)
if (fn ~ /^(assert|bashunit)/) {
if (filter == "" || index(fn, filter) > 0) {
should_print = 1
print $0
doc = ""
} else {
should_print = 0
}
next
}
}

if ((should_print)); then
# Check for code fence using pattern matching instead of regex
# Avoids backtick escaping issues in Bash 3.0
case "$line" in
'```'*)
echo "--------------"
echo "$docstring"
should_print=0
continue
;;
esac
if (should_print) {
if ($0 ~ /^```/) {
print "--------------"
print doc
should_print = 0
next
}
if ($0 ~ /^::: code-group/) next

case "$line" in
"::: code-group"*) continue ;;
esac

# Remove markdown link brackets and anchor tags
line="${line//[\[\]]/}"
line="$(sed -E 's/ *\(#[-a-z0-9]+\)//g' <<<"$line")"
docstring="$docstring$line"$'\n'
fi
done <<<"$(bashunit::doc::get_embedded_docs)"
# Remove markdown link brackets and anchor tags. The bracket class
# uses the POSIX []][ idiom: busybox awk (Alpine) rejects
# backslash-escaped brackets inside a bracket expression.
line = $0
gsub(/[][]/, "", line)
gsub(/ *\(#[-a-z0-9]+\)/, "", line)
doc = doc line "\n"
}
}
'
}
23 changes: 23 additions & 0 deletions tests/acceptance/bashunit_doc_forks_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for #832. `bashunit doc` used to fork an `echo | sed` pipe
# for every line of the ~1.6k-line assertions.md (plus another `sed` per
# printed docstring line): ~3200 forks and ~5s wall to print a text file. The
# doc path must parse the embedded docs in a single awk pass instead.
function test_doc_command_does_not_fork_sed_per_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "process tracing is unreliable under Git Bash" && return
fi

local trace
trace="$(PS4='+ ' bash -x ./bashunit doc equals 2>&1 >/dev/null)"

# Count real `sed` process executions. The doc path invokes `sed` via PATH
# (not a pinned absolute binary), so the traced command token is a bare
# `sed` — match both forms; `command -v sed` is a builtin and never shows.
local sed_forks
sed_forks="$(printf '%s\n' "$trace" | grep -cE '^\++ +(/[^ ]*/)?sed ' || true)"

assert_equals 0 "$sed_forks"
}
Loading