diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b442d39..859ff94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- Faster test execution: each test file's data-provider annotations are scanned once and cached, replacing a per-test `grep`+`sed` probe with a pure-bash lookup (no behaviour change) (#763) + ## [0.41.0](https://github.com/TypedDevs/bashunit/compare/0.40.0...0.41.0) - 2026-07-11 ### Added diff --git a/src/helpers.sh b/src/helpers.sh index 89dad828..4ae08505 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -292,29 +292,123 @@ function bashunit::helper::normalize_variable_name() { builtin echo "$normalized_string" } -function bashunit::helper::get_provider_data() { - local function_name="$1" - local script="$2" +# Provider map for the most recently scanned script. Scanning a file once and +# caching the test-function -> provider-function pairs replaces a per-test +# grep+sed fork with a pure-bash lookup on the hot path (issue #763). +_BASHUNIT_PROVIDER_MAP_SCRIPT="" +_BASHUNIT_PROVIDER_MAP_FNS=() +_BASHUNIT_PROVIDER_MAP_PROVIDERS=() +_BASHUNIT_PROVIDER_FN_OUT="" +# +# Resolves a script path, applying the issue #529 working-dir fallback. +# Writes the resolved path into _BASHUNIT_PROVIDER_RESOLVED_OUT (empty if unreadable). +# +_BASHUNIT_PROVIDER_RESOLVED_OUT="" +function bashunit::helper::_resolve_provider_script() { + local script=$1 # Handle directory changes in set_up_before_script (issue #529) - # If relative path doesn't exist, try with BASHUNIT_WORKING_DIR if [ ! -f "$script" ] && [ -n "${BASHUNIT_WORKING_DIR:-}" ]; then script="$BASHUNIT_WORKING_DIR/$script" fi - if [ ! -f "$script" ]; then + _BASHUNIT_PROVIDER_RESOLVED_OUT="" + return + fi + _BASHUNIT_PROVIDER_RESOLVED_OUT=$script +} + +# +# Scans a script once and caches its test-function -> provider-function pairs. +# Memoized by resolved path, so repeated calls for the same file do not rescan. +# +# @param $1 string Path to the test script +# +function bashunit::helper::build_provider_map() { + bashunit::helper::_resolve_provider_script "$1" + local script=$_BASHUNIT_PROVIDER_RESOLVED_OUT + + if [ -z "$script" ]; then + # Unreadable path: reset to an empty map keyed to this argument so a + # follow-up lookup returns empty without rescanning. + _BASHUNIT_PROVIDER_MAP_SCRIPT="$1" + _BASHUNIT_PROVIDER_MAP_FNS=() + _BASHUNIT_PROVIDER_MAP_PROVIDERS=() return fi - local data_provider_function - data_provider_function=$( - # shellcheck disable=SC1087 - grep -B 2 -E "(function[[:space:]]+)?$function_name[[:space:]]*\(\)" "$script" 2>/dev/null | - sed -nE 's/^[[:space:]]*# *@?data_provider[[:space:]]+//p' - ) + if [ "$script" = "$_BASHUNIT_PROVIDER_MAP_SCRIPT" ]; then + return + fi + + _BASHUNIT_PROVIDER_MAP_SCRIPT="$script" + _BASHUNIT_PROVIDER_MAP_FNS=() + _BASHUNIT_PROVIDER_MAP_PROVIDERS=() + + local count=0 + local fn provider + # Single awk pass emits "\t" for every function whose + # definition is at most two lines below a `# @data_provider` (or + # `# data_provider`) annotation, mirroring the previous grep -B2 + sed. + while IFS=$'\t' read -r fn provider; do + [ -z "$fn" ] && continue + _BASHUNIT_PROVIDER_MAP_FNS[count]="$fn" + _BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider" + count=$((count + 1)) + done < <(awk ' + /^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/ { + p = $0 + sub(/^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/, "", p) + sub(/[[:space:]]+$/, "", p) + pending = p + pending_line = NR + next + } + { + if (pending != "" && NR - pending_line <= 2) { + if (match($0, /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/)) { + fn = $0 + sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) + sub(/[[:space:]]*\(\).*/, "", fn) + printf "%s\t%s\n", fn, pending + pending = "" + } + } else if (pending != "" && NR - pending_line > 2) { + pending = "" + } + } + ' "$script" 2>/dev/null) +} + +# +# Pure-bash lookup against the cached provider map. +# Writes the provider-function name (or empty) into _BASHUNIT_PROVIDER_FN_OUT. +# +# @param $1 string Test-function name +# +function bashunit::helper::provider_for_function() { + local function_name=$1 + local i=0 + local total=${#_BASHUNIT_PROVIDER_MAP_FNS[@]} + while [ "$i" -lt "$total" ]; do + if [ "${_BASHUNIT_PROVIDER_MAP_FNS[i]}" = "$function_name" ]; then + _BASHUNIT_PROVIDER_FN_OUT="${_BASHUNIT_PROVIDER_MAP_PROVIDERS[i]}" + return + fi + i=$((i + 1)) + done + _BASHUNIT_PROVIDER_FN_OUT="" +} + +function bashunit::helper::get_provider_data() { + local function_name="$1" + local script="$2" + + bashunit::helper::build_provider_map "$script" + bashunit::helper::provider_for_function "$function_name" - if [ -n "$data_provider_function" ]; then - bashunit::helper::execute_function_if_exists "$data_provider_function" + if [ -n "$_BASHUNIT_PROVIDER_FN_OUT" ]; then + bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT" fi } @@ -379,15 +473,19 @@ function bashunit::helper::find_total_tests() { local -a provider_data=() local provider_data_count=0 local fn_name line + # Scan once; functions without a provider count as 1 with no fork (#763). + bashunit::helper::build_provider_map "$file" for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do - provider_data=() + bashunit::helper::provider_for_function "$fn_name" + if [ -z "$_BASHUNIT_PROVIDER_FN_OUT" ]; then + count=$((count + 1)) + continue + fi provider_data_count=0 while IFS=" " read -r line; do [ -z "$line" ] && continue - # shellcheck disable=SC2034 - provider_data[provider_data_count]="$line" provider_data_count=$((provider_data_count + 1)) - done <<<"$(bashunit::helper::get_provider_data "$fn_name" "$file")" + done <<<"$(bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT")" if [ "$provider_data_count" -eq 0 ]; then count=$((count + 1)) diff --git a/src/runner.sh b/src/runner.sh index fecd388e..c26c26d1 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -767,22 +767,17 @@ function bashunit::runner::call_test_functions() { local -a parsed_data=() local parsed_data_count=0 + # Scan the file once; per-test provider lookups below are pure-bash (#763). + bashunit::helper::build_provider_map "$script" + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then break fi - provider_data=() - provider_data_count=0 - local line - while IFS=" " read -r line; do - [ -z "$line" ] && continue - provider_data[provider_data_count]="$line" - provider_data_count=$((provider_data_count + 1)) - done <<<"$(bashunit::helper::get_provider_data "$fn_name" "$script")" - - # No data provider found - if [ "$provider_data_count" -eq 0 ]; then + # No data provider found: run once without forking to capture provider output. + bashunit::helper::provider_for_function "$fn_name" + if [ -z "$_BASHUNIT_PROVIDER_FN_OUT" ]; then if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then bashunit::runner::wait_for_job_slot bashunit::runner::run_test "$script" "$fn_name" & @@ -793,6 +788,15 @@ function bashunit::runner::call_test_functions() { continue fi + provider_data=() + provider_data_count=0 + local line + while IFS=" " read -r line; do + [ -z "$line" ] && continue + provider_data[provider_data_count]="$line" + provider_data_count=$((provider_data_count + 1)) + done <<<"$(bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT")" + # Execute the test function for each line of data local data for data in "${provider_data[@]+"${provider_data[@]}"}"; do diff --git a/tests/unit/fixtures/provider_map/sample_providers.sh b/tests/unit/fixtures/provider_map/sample_providers.sh new file mode 100644 index 00000000..296fc79f --- /dev/null +++ b/tests/unit/fixtures/provider_map/sample_providers.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Fixture for provider-map scanner tests. Not run as a suite; scanned as text. + +# @data_provider provide_at_form +function test_with_at_annotation() { + return 0 +} + +# data_provider provide_plain_form +function test_without_at() { + return 0 +} + +# @data_provider provide_two_lines_up +# shellcheck disable=SC2317 +function test_annotation_two_lines_up() { + return 0 +} + +# @data_provider provide_shared +function test_shares_provider_one() { + return 0 +} + +# @data_provider provide_shared +test_shares_provider_two() { + return 0 +} + +function test_without_provider() { + return 0 +} diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index 7f55c10d..8201b65d 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -225,6 +225,51 @@ function test_get_provider_data_should_returns_empty_when_not_exists_provider_fu "$(bashunit::helper::get_provider_data "fake_function_get_not_existing_provider_data" "${BASH_SOURCE[0]}")" } +FIXTURE_PROVIDER_MAP="$(dirname "${BASH_SOURCE[0]}")/fixtures/provider_map/sample_providers.sh" + +function provider_for() { + bashunit::helper::build_provider_map "$1" + bashunit::helper::provider_for_function "$2" + echo "$_BASHUNIT_PROVIDER_FN_OUT" +} + +function test_provider_map_resolves_at_annotation() { + assert_same "provide_at_form" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_with_at_annotation")" +} + +function test_provider_map_resolves_plain_annotation_without_at() { + assert_same "provide_plain_form" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_without_at")" +} + +function test_provider_map_resolves_annotation_two_lines_above_function() { + assert_same "provide_two_lines_up" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_annotation_two_lines_up")" +} + +function test_provider_map_resolves_shared_provider_for_both_functions() { + assert_same "provide_shared" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_shares_provider_one")" + assert_same "provide_shared" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_shares_provider_two")" +} + +function test_provider_map_returns_empty_when_function_has_no_provider() { + assert_same "" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_without_provider")" +} + +function test_provider_map_returns_empty_for_unknown_function() { + assert_same "" \ + "$(provider_for "$FIXTURE_PROVIDER_MAP" "test_does_not_exist")" +} + +function test_provider_map_returns_empty_for_unreadable_script() { + assert_same "" \ + "$(provider_for "/no/such/path/nope_test.sh" "test_with_at_annotation")" +} + function test_left_trim() { assert_same "foo" "$(bashunit::helper::trim " foo")" }