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

- Blog posts for releases 0.43.0 through 0.52.0, including the 0.50.1 cleanup patch

### Changed

- Large provider and annotation maps use an index instead of scanning earlier entries, speeding up test files on Bash 3.0 and newer (#1392)

## [0.52.0](https://github.com/TypedDevs/bashunit/compare/0.51.0...0.52.0) - 2026-10-11

### Changed
Expand Down
4 changes: 2 additions & 2 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Sixteen, in load order. The order is the dependency layering: leaves first.
| 6 | `coverage/` | 16 | 3277 | line/branch tracking, diff coverage and the five report formats |
| 7 | `state/` | 6 | 474 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1278 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 9 | 1071 | naming, discovery, data providers, tags, per-test annotations, encoding |
| 9 | `helper/` | 10 | 1789 | naming, discovery, data providers, tags, per-test annotations, encoding |
| 10 | `cli/` | 5 | 445 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2326 | every assertion |
| 12 | `doubles/` | 4 | 505 | spies and mocks — **sourced by `assert/index.sh`, not the entrypoint** |
Expand All @@ -65,7 +65,7 @@ Sixteen, in load order. The order is the dependency layering: leaves first.
| 15 | `benchmark/` | 6 | 649 | the bench implementation (`runner/bench.sh` is its loop) |
| 16 | `main/` | 8 | 1473 | flag parsing per subcommand and the run lifecycle |

The file counts sum to 114, which is every `.sh` file in `src/` — that is the check to re-run
The file counts sum to 115, which is every `.sh` file in `src/`. Re-run this check
when editing this table, because both of its previous errors came from counting the wrong way.
`doubles/` was absent entirely: the table was generated from the entrypoint's `source` lines,
and `doubles/` is the one module the entrypoint does not source, so it fell through while the
Expand Down
33 changes: 33 additions & 0 deletions adrs/adr-014-metadata-lookup-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Index provider and annotation metadata with shell variables

* Status: accepted
* Date: 2026-10-24

Related: https://github.com/TypedDevs/bashunit/issues/1392

## Context and Problem Statement

The provider scanner already reads a test file once. Its consumers still scan arrays of function names for every provider or annotation lookup. Resolving annotations in the runner and again inside each attempt repeats that work. A file with many annotated tests therefore spends increasing time finding metadata for later tests.

## Decision Outcome

Keep the arrays as the source of metadata values. Scan maps with up to 64 entries directly and build an index from function name to array position for larger maps. A 100-pair comparison found about 2 to 3ms of setup overhead for a 10-test annotated file when every map was indexed; avoiding that fixed cost keeps small files on their existing path. Use ordinary, unexported shell variables for index entries and Bash's indirect expansion to read them. This works on Bash 3.0 and newer, without an external command or a second implementation.

Index only the name domain emitted by the existing scanner: ASCII letters, digits, underscores and colons. A lookup outside that domain returns no entry. Escape underscores as `__` before escaping colons as `_c`, so different function names cannot produce the same variable name. Separate prefixes distinguish provider and annotation entries.

Record the first position for each name, preserving the previous scan's first-match behavior. Clear entries using the previous map's names before resetting its arrays. Build the index once after scanner output is loaded when the map exceeds the size limit. Small files, including the validated header-cache path, keep direct lookup. Recording another annotation invalidates its index flag so direct helper calls continue to see appended entries. The index has the same lifetime as its map and is inherited by test workers.

Use builtin `read` to assign an index variable by name. Its input is a small integer position. No index entry is exported and no generated shell text is evaluated. Missing entries use the existing empty/default return slots. If index construction fails, leave its flag off and use the arrays directly; the next reset removes any partial index entries.

## Alternatives

* Continue scanning all arrays: simplest storage, but large maps repeatedly walk earlier entries. Keep this path only for small maps.
* Cache only the last lookup: helps repeated data sets and attempts, but distinct tests still scan the map.
* Use associative arrays on Bash 4+: introduces a second implementation when native shell-variable lookup already handles the scanner's key domain on Bash 3.0.
* Search a serialized string table: an experiment was slower than the original loop on Bash 3.2 and was rejected.

## Consequences

The index adds one private shell variable per distinct provider or annotation name. Reset must remove these variables as well as clear the arrays. Changes to the scanner's name domain must update the key encoding and its identity tests.

A trace-budget regression checks that late and absent lookups execute a bounded number of shell commands. Behavioral tests cover identity, resets, first-match behavior, and execution across providers, annotations, filtering, random order and parallel workers. End-to-end timings measure the full run separately from lookup-only experiments.
40 changes: 24 additions & 16 deletions src/helper/annotations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ _BASHUNIT_ANNOT_REASON_OUT=""
# Empties the map. Called whenever the provider pass starts a new file.
##
function bashunit::helper::annotations_reset() {
bashunit::helper::metadata_index_reset ANNOT \
"${_BASHUNIT_ANNOT_MAP_FNS[@]+"${_BASHUNIT_ANNOT_MAP_FNS[@]}"}"
_BASHUNIT_ANNOT_MAP_INDEXED=false
_BASHUNIT_ANNOT_MAP_FNS=()
_BASHUNIT_ANNOT_MAP_TIMEOUTS=()
_BASHUNIT_ANNOT_MAP_RETRIES=()
Expand All @@ -36,6 +39,7 @@ function bashunit::helper::annotations_reset() {
##
function bashunit::helper::annotations_record() {
local count=${#_BASHUNIT_ANNOT_MAP_FNS[@]}
_BASHUNIT_ANNOT_MAP_INDEXED=false
_BASHUNIT_ANNOT_MAP_FNS[count]="$1"
_BASHUNIT_ANNOT_MAP_TIMEOUTS[count]="${2-}"
_BASHUNIT_ANNOT_MAP_RETRIES[count]="${3-}"
Expand All @@ -50,27 +54,31 @@ function bashunit::helper::annotations_record() {
# Arguments: $1 - test-function name
##
function bashunit::helper::annotations_for_function() {
local function_name=$1
local i=0
local function_name=$1 index=0
local total=${#_BASHUNIT_ANNOT_MAP_FNS[@]}

_BASHUNIT_ANNOT_TIMEOUT_OUT=""
_BASHUNIT_ANNOT_RETRY_OUT=""
_BASHUNIT_ANNOT_SKIP_OUT="false"
_BASHUNIT_ANNOT_REASON_OUT=""

while [ "$i" -lt "$total" ]; do
if [ "${_BASHUNIT_ANNOT_MAP_FNS[i]}" = "$function_name" ]; then
_BASHUNIT_ANNOT_TIMEOUT_OUT="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[i]}"
_BASHUNIT_ANNOT_RETRY_OUT="${_BASHUNIT_ANNOT_MAP_RETRIES[i]}"
if [ "${_BASHUNIT_ANNOT_MAP_SKIPS[i]}" = "1" ]; then
_BASHUNIT_ANNOT_SKIP_OUT="true"
fi
_BASHUNIT_ANNOT_REASON_OUT="${_BASHUNIT_ANNOT_MAP_REASONS[i]}"
return
fi
i=$((i + 1))
done
[ "$total" -gt 0 ] || return 0

if [ "$_BASHUNIT_ANNOT_MAP_INDEXED" = true ]; then
bashunit::helper::metadata_index_lookup ANNOT "$function_name"
index=$_BASHUNIT_METADATA_INDEX_OUT
[ -n "$index" ] || return 0
else
while [ "$index" -lt "$total" ]; do
[ "${_BASHUNIT_ANNOT_MAP_FNS[index]}" != "$function_name" ] || break
index=$((index + 1))
done
[ "$index" -lt "$total" ] || return 0
fi
_BASHUNIT_ANNOT_TIMEOUT_OUT="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[index]}"
_BASHUNIT_ANNOT_RETRY_OUT="${_BASHUNIT_ANNOT_MAP_RETRIES[index]}"
if [ "${_BASHUNIT_ANNOT_MAP_SKIPS[index]}" = "1" ]; then
_BASHUNIT_ANNOT_SKIP_OUT="true"
fi
_BASHUNIT_ANNOT_REASON_OUT="${_BASHUNIT_ANNOT_MAP_REASONS[index]}"
}

##
Expand Down
4 changes: 2 additions & 2 deletions src/helper/index.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
# Entry point for the src/helper/ module. `source` lines and comments only,
# for the reason recorded in adrs/adr-011-source-layout-and-build-pipeline.md.
#
# Sourced leaves first; only discovery.sh has cross-file callees (provider.sh
# and functions.sh).
# Source metadata indexing before the maps that use it, then discovery.
source "$BASHUNIT_ROOT_DIR/src/helper/naming.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/encoding.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/functions.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/git.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/metadata.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/annotations.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/provider.sh"
source "$BASHUNIT_ROOT_DIR/src/helper/tags.sh"
Expand Down
58 changes: 58 additions & 0 deletions src/helper/metadata.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash

_BASHUNIT_METADATA_KEY_OUT=""
_BASHUNIT_METADATA_INDEX_OUT=""
_BASHUNIT_METADATA_INDEX_LIMIT=64
_BASHUNIT_PROVIDER_MAP_INDEXED=false
_BASHUNIT_ANNOT_MAP_INDEXED=false

function bashunit::helper::metadata_key() {
_BASHUNIT_METADATA_KEY_OUT=""
case "$2" in
'' | *[!a-zA-Z0-9_:]*) return ;;
esac
local escaped="${2//_/__}"
# Escape underscores first so a colon cannot collide with a literal _c.
_BASHUNIT_METADATA_KEY_OUT="_BASHUNIT_METADATA_${1}_${escaped//:/_c}"
}

function bashunit::helper::metadata_index_reset() {
local group=$1 name
shift
[ "$#" -gt "$_BASHUNIT_METADATA_INDEX_LIMIT" ] || return 0
for name in "$@"; do
bashunit::helper::metadata_key "$group" "$name"
[ -z "$_BASHUNIT_METADATA_KEY_OUT" ] || unset "$_BASHUNIT_METADATA_KEY_OUT"
done
}

function bashunit::helper::metadata_index_record() {
bashunit::helper::metadata_key "$1" "$2"
[ -n "$_BASHUNIT_METADATA_KEY_OUT" ] || return 0
local key=$_BASHUNIT_METADATA_KEY_OUT
[ -z "${!key-}" ] || return 0
if ! IFS= read -r "${key?}" <<<"$3"; then
unset -v "${key?}"
return 1
fi
export -n "${key?}"
}

function bashunit::helper::metadata_index_lookup() {
_BASHUNIT_METADATA_INDEX_OUT=""
bashunit::helper::metadata_key "$1" "$2"
[ -n "$_BASHUNIT_METADATA_KEY_OUT" ] || return 0
local key=$_BASHUNIT_METADATA_KEY_OUT
_BASHUNIT_METADATA_INDEX_OUT="${!key-}"
}

function bashunit::helper::metadata_index_build() {
local group=$1 name index=0
shift
[ "$#" -gt "$_BASHUNIT_METADATA_INDEX_LIMIT" ] || return 1
for name in "$@"; do
bashunit::helper::metadata_index_record "$group" "$name" "$index" || return 1
index=$((index + 1))
done
return 0
}
38 changes: 29 additions & 9 deletions src/helper/provider.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ function bashunit::helper::build_provider_map() {
# 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::helper::metadata_index_reset PROVIDER \
"${_BASHUNIT_PROVIDER_MAP_FNS[@]+"${_BASHUNIT_PROVIDER_MAP_FNS[@]}"}"
_BASHUNIT_PROVIDER_MAP_INDEXED=false
_BASHUNIT_PROVIDER_MAP_FNS=()
_BASHUNIT_PROVIDER_MAP_PROVIDERS=()
_BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false
Expand Down Expand Up @@ -92,6 +95,9 @@ function bashunit::helper::build_provider_map() {
fi

_BASHUNIT_PROVIDER_MAP_SCRIPT="$script"
bashunit::helper::metadata_index_reset PROVIDER \
"${_BASHUNIT_PROVIDER_MAP_FNS[@]+"${_BASHUNIT_PROVIDER_MAP_FNS[@]}"}"
_BASHUNIT_PROVIDER_MAP_INDEXED=false
_BASHUNIT_PROVIDER_MAP_FNS=()
_BASHUNIT_PROVIDER_MAP_PROVIDERS=()
_BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false
Expand Down Expand Up @@ -266,6 +272,15 @@ function bashunit::helper::build_provider_map() {
_BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider"
count=$((count + 1))
done <<<"$scan_rows"

if bashunit::helper::metadata_index_build PROVIDER \
"${_BASHUNIT_PROVIDER_MAP_FNS[@]+"${_BASHUNIT_PROVIDER_MAP_FNS[@]}"}"; then
_BASHUNIT_PROVIDER_MAP_INDEXED=true
fi
if bashunit::helper::metadata_index_build ANNOT \
"${_BASHUNIT_ANNOT_MAP_FNS[@]+"${_BASHUNIT_ANNOT_MAP_FNS[@]}"}"; then
_BASHUNIT_ANNOT_MAP_INDEXED=true
fi
}


Expand All @@ -275,17 +290,22 @@ function bashunit::helper::build_provider_map() {
# Arguments: $1 - test-function name
#
function bashunit::helper::provider_for_function() {
local function_name=$1
local i=0
local function_name=$1 index=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=""
[ "$total" -gt 0 ] || return 0
if [ "$_BASHUNIT_PROVIDER_MAP_INDEXED" = true ]; then
bashunit::helper::metadata_index_lookup PROVIDER "$function_name"
index=$_BASHUNIT_METADATA_INDEX_OUT
[ -n "$index" ] || return 0
else
while [ "$index" -lt "$total" ]; do
[ "${_BASHUNIT_PROVIDER_MAP_FNS[index]}" != "$function_name" ] || break
index=$((index + 1))
done
[ "$index" -lt "$total" ] || return 0
fi
_BASHUNIT_PROVIDER_FN_OUT="${_BASHUNIT_PROVIDER_MAP_PROVIDERS[index]}"
}


Expand Down
44 changes: 44 additions & 0 deletions tests/acceptance/bashunit_annotations_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,47 @@ function test_a_malformed_annotation_is_counted_once_under_parallel() {
assert_contains 'name="Bad annotation beside a good file"' "$junit"
assert_contains "is not a non-negative integer" "$junit"
}

function test_metadata_stays_with_its_test_across_files_filters_and_random_order_sequential() {
run_indexed_metadata_fixture --no-parallel
}

function test_metadata_stays_with_its_test_across_files_filters_and_random_order_parallel() {
run_indexed_metadata_fixture --parallel
}

function run_indexed_metadata_fixture() {
local mode=$1 dir output status=0 i
dir="$(bashunit::temp_dir)"
printf '%s\n' \
'function indexed_rows() { printf "%s\n" alpha beta; }' \
'# @timeout 0' '# @retry 1' '# @data_provider indexed_rows' \
'function test_indexed_rows() {' \
' local marker="$METADATA_MARKERS/$1"' \
' printf "%s\n" attempt >>"$marker"' \
' assert_same 2 "$(wc -l <"$marker" | tr -d " ")"' \
'}' \
'# @skip metadata skip' \
'function test_indexed_skip() { printf "%s\n" "skip ran" >"$METADATA_MARKERS/unexpected"; }' \
>"$dir/first_test.sh"
for ((i = 0; i < 65; i++)); do
printf '# @timeout 0\n# @data_provider indexed_rows\nfunction metadata_padding_%s() { :; }\n' "$i"
done >>"$dir/first_test.sh"
printf '%s\n' \
'function test_indexed_plain() { assert_same plain plain; }' \
'# @skip must not carry into a selected test' \
'function test_indexed_excluded() { assert_same excluded selected; }' \
>"$dir/second_test.sh"

output=$(METADATA_MARKERS="$dir" ./bashunit "$mode" --env "$TEST_ENV_FILE" \
--random-order --seed 42 --filter indexed --exclude-filter indexed_excluded \
"$dir/first_test.sh" "$dir/second_test.sh" 2>&1) || status=$?

[ "$status" -eq 0 ] || printf '%s\n' "$output"
assert_same 0 "$status"
assert_contains '3 passed' "$output"
assert_contains '1 skipped' "$output"
assert_same '2' "$(wc -l <"$dir/alpha" | tr -d ' ')"
assert_same '2' "$(wc -l <"$dir/beta" | tr -d ' ')"
assert_file_not_exists "$dir/unexpected"
}
Loading
Loading