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: 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
Expand Down
132 changes: 115 additions & 17 deletions src/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<fn>\t<provider>" 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
}

Expand Down Expand Up @@ -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))
Expand Down
26 changes: 15 additions & 11 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" &
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/fixtures/provider_map/sample_providers.sh
Original file line number Diff line number Diff line change
@@ -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
}
45 changes: 45 additions & 0 deletions tests/unit/helpers_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
}
Expand Down
Loading