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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Changed
- Faster test execution: removed per-assertion and per-result subprocess forks on hot paths (test-function-name detection, ANSI stripping in `assert_equals`, spy index parsing, `file:line` filter parsing, result-line padding). Behaviour is unchanged

## [0.40.0](https://github.com/TypedDevs/bashunit/compare/0.39.1...0.40.0) - 2026-06-16

### Added
Expand Down
23 changes: 13 additions & 10 deletions src/assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,6 @@ function assert_contains() {
local -a actual_arr
actual_arr=("${@:2}")
local label_override=""
local label_override=""
local label_override=""
local label_override=""
local label_override=""
local label_override=""
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand Down Expand Up @@ -811,11 +806,19 @@ function assert_line_count() {
if [ -z "$input_str" ]; then
local actual=0
else
local actual
actual=$(echo "$input_str" | wc -l | tr -d '[:blank:]')
local additional_new_lines
additional_new_lines=$(grep -o '\\n' <<<"$input_str" | wc -l | tr -d '[:blank:]')
actual=$((actual + additional_new_lines))
# Count lines without forking: one line plus each real newline, plus each
# literal "\n" (backslash-n) escape, which counts as an extra line break.
local actual=1
local _rest="$input_str"
while [ "$_rest" != "${_rest#*$'\n'}" ]; do
_rest="${_rest#*$'\n'}"
actual=$((actual + 1))
done
_rest="$input_str"
while [ "$_rest" != "${_rest#*\\n}" ]; do
_rest="${_rest#*\\n}"
actual=$((actual + 1))
done
fi

if [ "$expected" != "$actual" ]; then
Expand Down
10 changes: 7 additions & 3 deletions src/clock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ function bashunit::clock::_choose_impl() {
if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then
local result
result=$(date +%s%N 2>/dev/null)
if [ "$(echo "$result" | "$GREP" -cv 'N' || true)" -gt 0 ] \
&& [ "$(echo "$result" | "$GREP" -cE '^[0-9]+$' || true)" -gt 0 ]; then
# A pure-digit result means %N expanded; a literal "N" (unsupported date)
# contains a non-digit, so the digits-only check alone is sufficient.
case "$result" in
'' | *[!0-9]*) ;;
*)
_BASHUNIT_CLOCK_NOW_IMPL="date"
return 0
fi
;;
esac
fi

# 3. Try Perl with Time::HiRes
Expand Down
4 changes: 2 additions & 2 deletions src/console_results.sh
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ function bashunit::console_results::print_successful_test() {
else
time_display="${duration}ms"
fi
full_line="$(printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")")"
full_line="$(bashunit::str::rpad "$line" "$time_display")"
fi

bashunit::state::print_line "successful" "$full_line"
Expand Down Expand Up @@ -471,7 +471,7 @@ function bashunit::console_results::print_risky_test() {
if bashunit::env::is_show_execution_time_enabled; then
local time_display
time_display=$(bashunit::console_results::format_duration "$duration")
full_line="$(printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")")"
full_line="$(bashunit::str::rpad "$line" "$time_display")"
fi

bashunit::state::print_line "risky" "$full_line"
Expand Down
51 changes: 35 additions & 16 deletions src/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ function bashunit::helper::find_test_function_name() {
local i
for ((i = 0; i < ${#FUNCNAME[@]}; i++)); do
local fn="${FUNCNAME[$i]}"
# Check if function starts with "test_" or "test" followed by uppercase
local _re='^test[A-Z]'
local _is_test=false
case "$fn" in test_*) _is_test=true ;; esac
if [ "$_is_test" = true ] || [ "$(echo "$fn" | "$GREP" -cE "$_re" || true)" -gt 0 ]; then
# Check if function starts with "test_" or "test" followed by uppercase.
# Pure-bash globs avoid forking echo+grep on every call-stack frame (hot path).
case "$fn" in
test_* | test[A-Z]*)
echo "$fn"
return
fi
;;
esac
done
# No test function found, use fallback (caller of the assertion)
# FUNCNAME[0] = bashunit::helper::find_test_function_name
Expand Down Expand Up @@ -100,6 +100,17 @@ function bashunit::helper::escape_single_quotes() {
function bashunit::helper::interpolate_function_name() {
local function_name="$1"
shift

# Placeholders look like "::N::", so a name without "::" can never interpolate.
# Short-circuit to skip the per-arg escape_single_quotes forks in that case.
case "$function_name" in
*::*) ;;
*)
echo "$function_name"
return
;;
esac

local -a args
local args_count=$#
args=("$@")
Expand Down Expand Up @@ -483,17 +494,25 @@ function bashunit::helper::parse_file_path_filter() {
filter="${input#*::}"
;;
*)
# Check for :number syntax (line number filter)
local _re='^(.+):([0-9]+)$'
if [ "$(echo "$input" | "$GREP" -cE "$_re" || true)" -gt 0 ]; then
file_path=$(echo "$input" | sed -nE 's/^(.+):([0-9]+)$/\1/p')
local line_number
line_number=$(echo "$input" | sed -nE 's/^(.+):([0-9]+)$/\2/p')
# Line number will be resolved to function name later
filter="__line__:${line_number}"
else
# Check for :number syntax (line number filter): a non-empty path, a
# colon, then digits to the end of string. Pure-bash parameter expansion
# avoids forking grep+sed.
local line_number="${input##*:}"
local maybe_path="${input%:*}"
case "$line_number" in
'' | *[!0-9]*)
file_path="$input"
fi
;;
*)
if [ -n "$maybe_path" ] && [ "$maybe_path" != "$input" ]; then
# Line number will be resolved to function name later
file_path="$maybe_path"
filter="__line__:${line_number}"
else
file_path="$input"
fi
;;
esac
;;
esac

Expand Down
20 changes: 17 additions & 3 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,30 @@ function bashunit::runner::resolve_test_location() {
fi
}

# Writes the interpolated test-function name into _BASHUNIT_RUNNER_INTERP_OUT.
# Arguments: $1 fn_name, $@ test arguments
function bashunit::runner::apply_interpolated_title() {
local fn_name=$1
shift

# Only "::N::"-style names interpolate; skip the capture fork for the rest.
case "$fn_name" in
*::*) ;;
*)
bashunit::state::reset_current_test_interpolated_function_name
_BASHUNIT_RUNNER_INTERP_OUT=$fn_name
return
;;
esac

local interpolated
interpolated="$(bashunit::helper::interpolate_function_name "$fn_name" "$@")"
if [ "$interpolated" != "$fn_name" ]; then
bashunit::state::set_current_test_interpolated_function_name "$interpolated"
else
bashunit::state::reset_current_test_interpolated_function_name
fi
printf '%s' "$interpolated"
_BASHUNIT_RUNNER_INTERP_OUT=$interpolated
}

# Hot-path result helpers below return their value via a dedicated global slot
Expand All @@ -117,6 +130,7 @@ _BASHUNIT_RUNNER_FIELD_OUT=""
_BASHUNIT_RUNNER_TOTAL_OUT=""
_BASHUNIT_RUNNER_TYPE_OUT=""
_BASHUNIT_RUNNER_OUTPUT_OUT=""
_BASHUNIT_RUNNER_INTERP_OUT=""

# Writes the value of an encoded field (##KEY=value##) into _BASHUNIT_RUNNER_FIELD_OUT.
# Arguments: $1 test_execution_result, $2 key
Expand Down Expand Up @@ -997,8 +1011,8 @@ function bashunit::runner::run_test() {
bashunit::runner::export_test_identity "$test_file" "$fn_name"

bashunit::state::reset_test_title
local interpolated_fn_name
interpolated_fn_name=$(bashunit::runner::apply_interpolated_title "$fn_name" "$@")
bashunit::runner::apply_interpolated_title "$fn_name" "$@"
local interpolated_fn_name=$_BASHUNIT_RUNNER_INTERP_OUT
local current_assertions_failed="$_BASHUNIT_ASSERTIONS_FAILED"
local current_assertions_snapshot="$_BASHUNIT_ASSERTIONS_SNAPSHOT"
local current_assertions_incomplete="$_BASHUNIT_ASSERTIONS_INCOMPLETE"
Expand Down
67 changes: 39 additions & 28 deletions src/str.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
# Strip ANSI escape codes and control characters
function bashunit::str::strip_ansi() {
local input="$1"
# Fast path: plain text with no backslash (echo -e no-op) and no control
# bytes (nothing for sed to strip) passes through unchanged. Avoids forking
# echo+sed on the common case, which runs twice per assert_equals.
case "$input" in
*\\* | *[[:cntrl:]]*) ;;
*)
echo -e "$input"
return
;;
esac
echo -e "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g'
}

Expand All @@ -28,41 +38,42 @@ function bashunit::str::rpad() {
is_truncated=true
fi

# Rebuild the text with ANSI codes intact, preserving the truncation
local result_left_text=""
local i=0
local j=0
while [ $i -lt ${#clean_left_text} ] && [ $j -lt ${#left_text} ]; do
local char="${clean_left_text:$i:1}"
local original_char="${left_text:$j:1}"

# If the current character is part of an ANSI sequence, skip it and copy it
if [ "$original_char" = $'\x1b' ]; then
while [ "${left_text:$j:1}" != "m" ] && [ $j -lt ${#left_text} ]; do
result_left_text="$result_left_text${left_text:$j:1}"
((++j))
done
result_left_text="$result_left_text${left_text:$j:1}" # Append the final 'm'
((++j))
elif [ "$char" = "$original_char" ]; then
# Match the actual character
result_left_text="$result_left_text$char"
((++i))
((++j))
else
((++j))
fi
done

local result_left_text
local remaining_space
if $is_truncated; then
# Rebuild char-by-char with ANSI codes intact, applying the truncation.
result_left_text=""
local i=0
local j=0
while [ $i -lt ${#clean_left_text} ] && [ $j -lt ${#left_text} ]; do
local char="${clean_left_text:$i:1}"
local original_char="${left_text:$j:1}"

# If the current character is part of an ANSI sequence, skip it and copy it
if [ "$original_char" = $'\x1b' ]; then
while [ "${left_text:$j:1}" != "m" ] && [ $j -lt ${#left_text} ]; do
result_left_text="$result_left_text${left_text:$j:1}"
((++j))
done
result_left_text="$result_left_text${left_text:$j:1}" # Append the final 'm'
((++j))
elif [ "$char" = "$original_char" ]; then
# Match the actual character
result_left_text="$result_left_text$char"
((++i))
((++j))
else
((++j))
fi
done
result_left_text="$result_left_text..."
# 1: due to a blank space
# 3: due to the appended ...
remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3))
else
# Copy any remaining characters after the truncation point
result_left_text="$result_left_text${left_text:$j}"
# Not truncated: the visible text fits, so the original (ANSI intact) is
# already correct — skip the per-character rebuild entirely.
result_left_text="$left_text"
remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1))
fi

Expand Down
9 changes: 7 additions & 2 deletions src/test_doubles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,15 @@ function assert_have_been_called_with() {
shift

local index=""
if [ "$(echo "${!#}" | "$GREP" -cE '^[0-9]+$' || true)" -gt 0 ]; then
# A trailing all-digits arg selects the nth recorded call. Pure-bash glob
# avoids forking echo+grep on every assertion.
case "${!#}" in
'' | *[!0-9]*) ;;
*)
index=${!#}
set -- "${@:1:$#-1}"
fi
;;
esac

local expected="$*"

Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_cd_in_setup_before_script_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_subsequent_tests_run_when_set_up_before_script_changes_directory() {
local first_file=./tests/acceptance/fixtures/test_cd_in_setup_before_script_first.sh
local second_file=./tests/acceptance/fixtures/test_cd_in_setup_before_script_second.sh
Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_hook_source_failure_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_bashunit_when_tear_down_sources_nonexistent_file() {
local test_file=./tests/acceptance/fixtures/test_bashunit_when_teardown_sources_nonexistent_file.sh

Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_risky_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_bashunit_risky_test_shows_warning() {
local test_file=./tests/acceptance/fixtures/test_bashunit_risky_no_assertions.sh

Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_setup_before_script_error_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_bashunit_when_set_up_before_script_errors() {
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_before_script_errors.sh
local fixture=$test_file
Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_setup_error_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_bashunit_when_set_up_errors() {
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_errors.sh
local fixture=$test_file
Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_syntax_error_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_bashunit_when_test_file_has_syntax_error() {
local test_file=./tests/acceptance/fixtures/test_bashunit_when_syntax_error.bash

Expand Down
4 changes: 0 additions & 4 deletions tests/acceptance/bashunit_teardown_after_script_error_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}

function test_bashunit_when_tear_down_after_script_errors() {
local test_file=./tests/acceptance/fixtures/test_bashunit_when_teardown_after_script_errors.sh
local fixture=$test_file
Expand Down
Loading
Loading