From e4ea10503f06ee523330588bce4d0fc4df5d670f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 18:45:12 +0200 Subject: [PATCH 01/14] perf(helpers): drop grep fork in find_test_function_name hot path Walks the call stack on every assertion; the camelCase check forked echo+grep per frame. Replace with a pure-bash case glob (test_* | test[A-Z]*). Add a characterization test locking camelCase frame detection. --- src/helpers.sh | 12 ++++++------ tests/unit/custom_assertions_test.sh | 12 ++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/helpers.sh b/src/helpers.sh index 0eb9645e..04d2ac1b 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -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 diff --git a/tests/unit/custom_assertions_test.sh b/tests/unit/custom_assertions_test.sh index df27fb61..71b9f0fb 100644 --- a/tests/unit/custom_assertions_test.sh +++ b/tests/unit/custom_assertions_test.sh @@ -126,6 +126,18 @@ function test_helper_find_test_function_name_from_nested_function() { assert_same "test_helper_find_test_function_name_from_nested_function" "$found_name" } +function test_helper_find_test_function_name_detects_camelcase_frame() { + # A camelCase frame (testXxx) must be detected, not just snake_case (test_xxx) + testCamelCaseInner() { + bashunit::helper::find_test_function_name + } + + local found_name + found_name="$(testCamelCaseInner)" + + assert_same "testCamelCaseInner" "$found_name" +} + function test_helper_find_test_function_name_from_deeply_nested() { # Test from deeply nested functions _level3() { From 819589d52a7ae4b154fdecd27df72f7ab7831e5f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 18:51:38 +0200 Subject: [PATCH 02/14] perf(assert): add fast path to strip_ansi for plain text strip_ansi forked echo+sed twice per assert_equals, even on the success path. Plain text with no backslash or control bytes is unchanged by the sed, so return it directly via a pure-bash case guard (~80x faster on that path). Differential-verified identical output across edge cases; add characterization tests. --- src/str.sh | 10 ++++++++++ tests/unit/str_test.sh | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/str.sh b/src/str.sh index b63232eb..7aa8d11e 100644 --- a/src/str.sh +++ b/src/str.sh @@ -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' } diff --git a/tests/unit/str_test.sh b/tests/unit/str_test.sh index 54e71a58..8b61ca68 100644 --- a/tests/unit/str_test.sh +++ b/tests/unit/str_test.sh @@ -2,6 +2,26 @@ # shellcheck disable=SC2155 +function test_strip_ansi_plain_text_is_unchanged() { + assert_same "hello world" "$(bashunit::str::strip_ansi "hello world")" +} + +function test_strip_ansi_removes_color_codes() { + local colored=$(printf "\033[32mok\033[0m") + + assert_same "ok" "$(bashunit::str::strip_ansi "$colored")" +} + +function test_strip_ansi_removes_control_chars() { + local tabbed=$(printf "a\tb") + + assert_same "ab" "$(bashunit::str::strip_ansi "$tabbed")" +} + +function test_strip_ansi_empty_input() { + assert_same "" "$(bashunit::str::strip_ansi "")" +} + function test_rpad_default_width_padding_and_empty_left_text() { export TERMINAL_WIDTH=30 From 15ab5825678ac69e865f5a70fe0a3f225438011e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 18:53:07 +0200 Subject: [PATCH 03/14] ref(assert): collapse duplicated label_override declaration in assert_contains assert_contains declared the same 'local label_override=""' six times (copy-paste artifact). Reduce to one. --- src/assert.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index 35beb171..02e46494 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -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[@]}") From 5a5592108c373354cfcee267d7878d50100436e6 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 18:58:08 +0200 Subject: [PATCH 04/14] perf(doubles): drop grep fork for index arg in assert_have_been_called_with Detecting a trailing numeric index forked echo+grep on every call. Use a pure-bash case glob ('' | *[!0-9]*), matching the integer-check style already used in env.sh. --- src/test_doubles.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test_doubles.sh b/src/test_doubles.sh index e9f82b7b..48810ddc 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -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="$*" From 54284b627ea8df4317a290cb73568cc3e2ebc8b7 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:01:50 +0200 Subject: [PATCH 05/14] test(assert): lock strip_ansi fast/slow path boundary Add cases for glob metacharacters and percent signs (fast path, unchanged) and a backslash escape (slow path, echo -e expands then sed strips). Guards the strip_ansi fast-path optimization against regressions. --- tests/unit/str_test.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/str_test.sh b/tests/unit/str_test.sh index 8b61ca68..3839db9c 100644 --- a/tests/unit/str_test.sh +++ b/tests/unit/str_test.sh @@ -22,6 +22,21 @@ function test_strip_ansi_empty_input() { assert_same "" "$(bashunit::str::strip_ansi "")" } +function test_strip_ansi_glob_chars_are_unchanged() { + # Glob metacharacters must not trigger the control-char slow path + assert_same "a*b?c[d]" "$(bashunit::str::strip_ansi "a*b?c[d]")" +} + +function test_strip_ansi_percent_is_unchanged() { + assert_same "100% done" "$(bashunit::str::strip_ansi "100% done")" +} + +function test_strip_ansi_backslash_escape_is_expanded_then_stripped() { + # A literal backslash sends input through the slow path, where echo -e + # expands "\t" to a tab and sed then strips it as a control char + assert_same "col1col2" "$(bashunit::str::strip_ansi "col1\\tcol2")" +} + function test_rpad_default_width_padding_and_empty_left_text() { export TERMINAL_WIDTH=30 From 2dae0379a3b8f879060dd43d1cdf42098802ec5d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:05:38 +0200 Subject: [PATCH 06/14] perf(cli): parse file:line filter with pure-bash param expansion parse_file_path_filter forked grep plus two seds to split a 'file:line' filter. Replace with parameter expansion guarded by a digit-only case. Differential-verified identical across edge cases; add a colon-followed-by-non-number test. --- src/helpers.sh | 28 ++++++++++++++++++---------- tests/unit/helpers_test.sh | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/helpers.sh b/src/helpers.sh index 04d2ac1b..617d08af 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -483,17 +483,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 diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index 08b7278e..73f3e79c 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -421,6 +421,20 @@ function test_parse_file_path_filter_with_line_number() { assert_same "__line__:42" "$filter" } +function test_parse_file_path_filter_colon_followed_by_non_number() { + local result + result=$(bashunit::helper::parse_file_path_filter "tests/unit/example_test.sh:foo") || true + + local file_path filter + { + read -r file_path || true + read -r filter || true + } <<<"$result" + + assert_same "tests/unit/example_test.sh:foo" "$file_path" + assert_same "" "$filter" +} + function test_parse_file_path_filter_with_colon_in_path() { local result result=$(bashunit::helper::parse_file_path_filter "/path/to:weird/example_test.sh::test_func") || true From c6136e46d3b6d0097bf48e0269d739b8415f1a44 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:09:26 +0200 Subject: [PATCH 07/14] test(acceptance): centralize strip_ansi helper into bootstrap Eight acceptance tests defined an identical strip_ansi() helper. Move one copy to tests/bootstrap.sh (loaded by default for all runs) and drop the duplicates. --- tests/acceptance/bashunit_cd_in_setup_before_script_test.sh | 4 ---- tests/acceptance/bashunit_hook_source_failure_test.sh | 4 ---- tests/acceptance/bashunit_risky_test.sh | 4 ---- tests/acceptance/bashunit_setup_before_script_error_test.sh | 4 ---- tests/acceptance/bashunit_setup_error_test.sh | 4 ---- tests/acceptance/bashunit_syntax_error_test.sh | 4 ---- .../acceptance/bashunit_teardown_after_script_error_test.sh | 4 ---- tests/acceptance/bashunit_teardown_error_test.sh | 4 ---- tests/bootstrap.sh | 6 ++++++ 9 files changed, 6 insertions(+), 32 deletions(-) diff --git a/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh b/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh index 0a400641..4f94912d 100644 --- a/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh +++ b/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_hook_source_failure_test.sh b/tests/acceptance/bashunit_hook_source_failure_test.sh index 211e4deb..68359375 100644 --- a/tests/acceptance/bashunit_hook_source_failure_test.sh +++ b/tests/acceptance/bashunit_hook_source_failure_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_risky_test.sh b/tests/acceptance/bashunit_risky_test.sh index dc1126c1..b84e9a58 100644 --- a/tests/acceptance/bashunit_risky_test.sh +++ b/tests/acceptance/bashunit_risky_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_setup_before_script_error_test.sh b/tests/acceptance/bashunit_setup_before_script_error_test.sh index b6f25071..996aa767 100644 --- a/tests/acceptance/bashunit_setup_before_script_error_test.sh +++ b/tests/acceptance/bashunit_setup_before_script_error_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_setup_error_test.sh b/tests/acceptance/bashunit_setup_error_test.sh index 8f924366..dde14e3e 100644 --- a/tests/acceptance/bashunit_setup_error_test.sh +++ b/tests/acceptance/bashunit_setup_error_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_syntax_error_test.sh b/tests/acceptance/bashunit_syntax_error_test.sh index 2785ed22..2f0a1f1e 100644 --- a/tests/acceptance/bashunit_syntax_error_test.sh +++ b/tests/acceptance/bashunit_syntax_error_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_teardown_after_script_error_test.sh b/tests/acceptance/bashunit_teardown_after_script_error_test.sh index 955b1633..30dfb2e6 100644 --- a/tests/acceptance/bashunit_teardown_after_script_error_test.sh +++ b/tests/acceptance/bashunit_teardown_after_script_error_test.sh @@ -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 diff --git a/tests/acceptance/bashunit_teardown_error_test.sh b/tests/acceptance/bashunit_teardown_error_test.sh index ee65205f..07b32864 100644 --- a/tests/acceptance/bashunit_teardown_error_test.sh +++ b/tests/acceptance/bashunit_teardown_error_test.sh @@ -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_errors() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_teardown_errors.sh local fixture=$test_file diff --git a/tests/bootstrap.sh b/tests/bootstrap.sh index debee88e..2322392d 100644 --- a/tests/bootstrap.sh +++ b/tests/bootstrap.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail +# Strips ANSI escape sequences from stdin. Shared by acceptance tests that +# compare rendered CLI output against plain-text expectations. +function strip_ansi() { + sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' +} + function mock_non_existing_fn() { return 127 } From f739f6b3a397097d29219f2a89d4e486d96580df Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:13:16 +0200 Subject: [PATCH 08/14] perf(runner): drop redundant subshell in per-test result rendering print_successful_test and print_risky_test wrapped rpad output in 'printf %s\n' inside another command substitution, which immediately strips the added newline. Capture rpad directly, saving a fork per rendered test line. --- src/console_results.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/console_results.sh b/src/console_results.sh index 2d4a8c7a..c3149814 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -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" @@ -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" From 00690f9d4de1062751fee0a9298753cd1a6b1e3d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:16:55 +0200 Subject: [PATCH 09/14] perf(runner): skip rpad char-by-char rebuild when text is not truncated rpad rebuilt the left text character-by-character to preserve ANSI codes across a possible truncation. When the visible text fits the width (the common case for result lines), that loop just reconstructs the original. Use the original directly in that branch and run the rebuild only when truncating (~30% faster per colored result line). Output verified identical across truncation/ANSI/edge cases. --- src/str.sh | 57 +++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/str.sh b/src/str.sh index 7aa8d11e..6a969651 100644 --- a/src/str.sh +++ b/src/str.sh @@ -38,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 From 5f7d68468b2bdf9a89e9b68ffa88d4d9b55e2fa2 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:20:27 +0200 Subject: [PATCH 10/14] docs(changelog): note hot-path performance improvements --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afe4b4a3..43dfa3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 8af0aff21321e18e7c4db716b3e3cc5320cfd55d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:26:06 +0200 Subject: [PATCH 11/14] ref(clock): use pure-bash case for date +%s%N digit check Replaced two grep forks (a redundant no-'N' check plus a digits-only check) with a single case glob. A pure-digit result already excludes a literal 'N', so the digits-only test is sufficient. Matches the integer-check style used in env.sh. --- src/clock.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/clock.sh b/src/clock.sh index 51641a71..f3e8cb8b 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -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 From 70a6dbdc7421a07e0bc43163056d9ba9c938c335 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 19:54:21 +0200 Subject: [PATCH 12/14] perf(assert): count lines in assert_line_count without forking Replaced echo|wc|tr and grep|wc|tr pipelines with pure-bash newline counting (real newlines plus literal backslash-n escapes). Removes three forks per call and the dependency on echo not interpreting backslashes. Counts verified identical to the old implementation across empty/single/real-newline/literal/mixed inputs. --- src/assert.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index 02e46494..1a23a02a 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -806,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 From bb9bf2e1280f6489164deb09f326ff6aca623eeb Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 20:00:02 +0200 Subject: [PATCH 13/14] perf(runner): short-circuit interpolate_function_name when name has no placeholder Interpolation placeholders are '::N::', so a function name without '::' can never interpolate. Return it immediately in that case, skipping the per-argument escape_single_quotes forks that ran for every data-provider test whose name had no placeholder. Output verified identical; add a no-placeholder-with-args test. --- src/helpers.sh | 11 +++++++++++ tests/unit/helpers_test.sh | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/src/helpers.sh b/src/helpers.sh index 617d08af..89dad828 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -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=("$@") diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index 73f3e79c..7f55c10d 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -321,6 +321,13 @@ function test_interpolate_fn_name() { assert_same "test_name_'bar'_foo" "$result" } +function test_interpolate_fn_name_without_placeholder_ignores_args() { + local result + result="$(bashunit::helper::interpolate_function_name "test_plain_name" "bar" "baz")" + + assert_same "test_plain_name" "$result" +} + function test_normalize_test_function_name_with_interpolation() { local fn="test_returns_value_::1::_and_::2::_given" # shellcheck disable=SC2155 From 92206c5b75d3783a4939d8b6e64be0ffd57fdef9 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 18 Jun 2026 20:05:22 +0200 Subject: [PATCH 14/14] perf(runner): return interpolated title via outvar slot apply_interpolated_title returned via stdout, forcing a per-test $(...) capture, and itself captured interpolate_function_name. Switch it to the documented _BASHUNIT_RUNNER_*_OUT slot pattern and short-circuit non-'::' names, removing two forks per test for the common (non-interpolated) case. State side-effects and output are unchanged. --- src/runner.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/runner.sh b/src/runner.sh index b47bf547..dd91eb42 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -89,9 +89,22 @@ 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 @@ -99,7 +112,7 @@ function bashunit::runner::apply_interpolated_title() { 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 @@ -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 @@ -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"