Skip to content
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Contributions are licensed under the [MIT License](https://github.com/TypedDevs/

### Prerequisites

- Bash 3.2+
- Bash 3.0+
- Git
- Make
- [ShellCheck](https://github.com/koalaman/shellcheck#installing)
Expand Down
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ An open-source **library** providing a fast, portable Bash testing framework: **
* Minimal overhead, plain Bash test files.
* Rich **assertions**, **test doubles (mock/spy)**, **data providers**, **snapshots**, **skip/todo**, **globals utilities**, **custom assertions**, **benchmarks**, and **standalone** runs.

**Compatibility**: Bash 3.2+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools.
**Compatibility**: Bash 3.0+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools.

---

Expand Down Expand Up @@ -284,7 +284,7 @@ We practice two nested feedback loops to deliver behavior safely and quickly.

### Compatibility & Portability
```bash
# βœ… GOOD - Works on Bash 3.2+
# βœ… GOOD - Works on Bash 3.0+
[[ -n "${var:-}" ]] && echo "set"
array=("item1" "item2")

Expand Down Expand Up @@ -1000,7 +1000,7 @@ Use this template for internal changes, fixes, refactors, documentation.
- **All tests pass** (`./bashunit tests/`)
- **Shellcheck passes** with existing exceptions (`shellcheck -x $(find . -name "*.sh")`)
- **Code formatted** (`shfmt -w .`)
- **Bash 3.2+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`)
- **Bash 3.0+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`)
- **Follows established module namespacing** patterns

### βœ… Testing (following observed patterns)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- tear_down
- set_up_before_script
- tear_down_after_script
- Support Bash 3.0 (Previously 3.2)

## [0.24.0](https://github.com/TypedDevs/bashunit/compare/0.23.0...0.24.0) - 2025-09-14

Expand Down
8 changes: 4 additions & 4 deletions bashunit
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

declare -r BASHUNIT_MIN_BASH_VERSION="3.2"
declare -r BASHUNIT_MIN_BASH_VERSION="3.0"

function _check_bash_version() {
local current_version
Expand All @@ -16,10 +16,10 @@ function _check_bash_version() {
current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)"
fi

local major minor
IFS=. read -r major minor _ <<< "$current_version"
local major
major=$(echo "$current_version" | cut -d. -f1)

if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then
if (( major < 3 )); then
printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2
exit 1
fi
Expand Down
3 changes: 2 additions & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ function build::process_file() {
sourced_file=$(eval echo "$sourced_file")

# Handle relative paths if necessary
if [[ ! "$sourced_file" =~ ^/ ]]; then
local absolute_path_pattern='^/'
if [[ ! "$sourced_file" =~ $absolute_path_pattern ]]; then
sourced_file="$(dirname "$file")/$sourced_file"
fi

Expand Down
3 changes: 2 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ DIR="lib"
VERSION="latest"

function is_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$1" == "latest" || "$1" == "beta" ]]
local version_pattern='^[0-9]+\.[0-9]+\.[0-9]+$'
[[ "$1" =~ $version_pattern || "$1" == "latest" || "$1" == "beta" ]]
}

# Parse arguments flexibly
Expand Down
8 changes: 5 additions & 3 deletions src/assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ function assert_false() {

function run_command_or_eval() {
local cmd="$1"
local eval_pattern='^eval'
local alias_pattern='^alias'

if [[ "$cmd" =~ ^eval ]]; then
if [[ "$cmd" =~ $eval_pattern ]]; then
eval "${cmd#eval }" &> /dev/null
elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then
elif [[ "$(command -v "$cmd")" =~ $alias_pattern ]]; then
Comment on lines +54 to +59

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many cases of rewriting the regex matching this way, but this can simply be solved by defining a utility function like function regex_match() { [[ $1 =~ $2 ]]; }. Then, you can write the regular expressions inline as regex_match "$cmd" '^eval' or regex_match "$(command -v "$cmd")" '^alias'. This works in all cases of bash <= 3.1, bash >= 3.2, and bash >= 3.2 with shopt -s compat31.

eval "$cmd" &> /dev/null
else
"$cmd" &> /dev/null
Expand Down Expand Up @@ -546,7 +548,7 @@ function assert_line_count() {
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:]')
additional_new_lines=$(echo "$input_str" | grep -o '\\n' | wc -l | tr -d '[:blank:]')
Comment on lines -563 to +565

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this change try to fix? <<< "$input_str" works in Bash 3.0.

((actual+=additional_new_lines))
fi

Expand Down
27 changes: 18 additions & 9 deletions src/benchmark.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,27 @@ function benchmark::parse_annotations() {
local annotation
annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script")

if [[ $annotation =~ @revs=([0-9]+) ]]; then
local revs_pattern='@revs=([0-9]+)'
local revolutions_pattern='@revolutions=([0-9]+)'
local its_pattern='@its=([0-9]+)'
local iterations_pattern='@iterations=([0-9]+)'
local max_ms_pattern='@max_ms=([0-9.]+)'

if [[ $annotation =~ $revs_pattern ]]; then
revs="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @revolutions=([0-9]+) ]]; then
elif [[ $annotation =~ $revolutions_pattern ]]; then
revs="${BASH_REMATCH[1]}"
fi

if [[ $annotation =~ @its=([0-9]+) ]]; then
if [[ $annotation =~ $its_pattern ]]; then
its="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @iterations=([0-9]+) ]]; then
elif [[ $annotation =~ $iterations_pattern ]]; then
its="${BASH_REMATCH[1]}"
fi

if [[ $annotation =~ @max_ms=([0-9.]+) ]]; then
if [[ $annotation =~ $max_ms_pattern ]]; then
max_ms="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @max_ms=([0-9.]+) ]]; then
elif [[ $annotation =~ $max_ms_pattern ]]; then
max_ms="${BASH_REMATCH[1]}"
fi

Expand All @@ -55,7 +61,8 @@ function benchmark::run_function() {
local revs=$2
local its=$3
local max_ms=$4
local durations=()
local durations
durations=()

for ((i=1; i<=its; i++)); do
local start_time=$(clock::now)
Expand Down Expand Up @@ -129,13 +136,15 @@ function benchmark::print_results() {

if (( $(echo "$avg <= $max_ms" | bc -l) )); then
local raw="≀ ${max_ms}"
printf -v padded "%14s" "$raw"
local padded
padded=$(printf "%14s" "$raw")
printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded"
continue
fi

local raw="> ${max_ms}"
printf -v padded "%12s" "$raw"
local padded
padded=$(printf "%12s" "$raw")
printf '%-40s %6s %6s %10s %s%s%s\n' \
"$name" "$revs" "$its" "$avg" \
"$_COLOR_FAILED" "$padded" "${_COLOR_DEFAULT}"
Expand Down
6 changes: 4 additions & 2 deletions src/clock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ _CLOCK_NOW_IMPL=""

function clock::_choose_impl() {
local shell_time
local attempts=()
local attempts
attempts=()

# 1. Try Perl with Time::HiRes
attempts+=("Perl")
Expand Down Expand Up @@ -37,8 +38,9 @@ function clock::_choose_impl() {
attempts+=("date")
if ! check_os::is_macos && ! check_os::is_alpine; then
local result
local number_pattern='^[0-9]+$'
result=$(date +%s%N 2>/dev/null)
if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then
if [[ "$result" != *N && "$result" =~ $number_pattern ]]; then
_CLOCK_NOW_IMPL="date"
return 0
fi
Expand Down
30 changes: 21 additions & 9 deletions src/helpers.sh

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lines 50 and 244: local name=(...) creates a scalar variable local name="(...)" in Bash 3.0. It doesn't create an array.

lines 53, 124, 306, and 326: Loop variables i, fn, and line are leaked.

Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function helper::normalize_test_function_name() {
# Replace underscores with spaces
result="${result//_/ }"
# Capitalize the first letter
result="$(tr '[:lower:]' '[:upper:]' <<< "${result:0:1}")${result:1}"
result="$(echo "${result:0:1}" | tr '[:lower:]' '[:upper:]')${result:1}"

echo "$result"
}
Expand Down Expand Up @@ -160,7 +160,8 @@ function helper::find_files_recursive() {
local pattern="${2:-*[tT]est.sh}"

local alt_pattern=""
if [[ $pattern == *test.sh ]] || [[ $pattern =~ \[tT\]est\.sh$ ]]; then
local test_pattern='\[tT\]est\.sh$'
if [[ $pattern == *test.sh ]] || [[ $pattern =~ $test_pattern ]]; then
alt_pattern="${pattern%.sh}.bash"
fi

Expand All @@ -187,7 +188,8 @@ function helper::normalize_variable_name() {

normalized_string="${input_string//[^a-zA-Z0-9_]/_}"

if [[ ! $normalized_string =~ ^[a-zA-Z_] ]]; then
local valid_start_pattern='^[a-zA-Z_]'
if [[ ! $normalized_string =~ $valid_start_pattern ]]; then
normalized_string="_$normalized_string"
fi

Expand Down Expand Up @@ -269,10 +271,18 @@ function helper::find_total_tests() {
# shellcheck disable=SC2207
local functions_to_run=($filtered_functions)
for fn_name in "${functions_to_run[@]}"; do
local provider_data=()
while IFS=" " read -r line; do
provider_data+=("$line")
done <<< "$(helper::get_provider_data "$fn_name" "$file")"
local provider_data
provider_data=()
local provider_output
provider_output="$(helper::get_provider_data "$fn_name" "$file")"
if [[ -n "$provider_output" ]]; then
local line
while IFS=" " read -r line; do
provider_data+=("$line")
done << EOF
$provider_output
EOF
fi

if [[ "${#provider_data[@]}" -eq 0 ]]; then
count=$((count + 1))
Expand All @@ -295,7 +305,8 @@ function helper::load_test_files() {
local filter=$1
local files=("${@:2}")

local test_files=()
local test_files
test_files=()

if [[ "${#files[@]}" -eq 0 ]]; then
if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then
Expand All @@ -314,7 +325,8 @@ function helper::load_bench_files() {
local filter=$1
local files=("${@:2}")

local bench_files=()
local bench_files
bench_files=()

if [[ "${#files[@]}" -eq 0 ]]; then
if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then
Expand Down
9 changes: 6 additions & 3 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ function main::exec_tests() {
local filter=$1
local files=("${@:2}")

local test_files=()
local test_files
test_files=()
while IFS= read -r line; do
test_files+=("$line")
done < <(helper::load_test_files "$filter" "${files[@]}")
Expand Down Expand Up @@ -82,7 +83,8 @@ function main::exec_benchmarks() {
local filter=$1
local files=("${@:2}")

local bench_files=()
local bench_files
bench_files=()
while IFS= read -r line; do
bench_files+=("$line")
done < <(helper::load_bench_files "$filter" "${files[@]}")
Expand Down Expand Up @@ -189,7 +191,8 @@ function main::handle_assert_exit_code() {
last_line=$(echo "$output" | tail -n 1)
if echo "$last_line" | grep -q 'inner_exit_code:[0-9]*'; then
inner_exit_code=$(echo "$last_line" | grep -o 'inner_exit_code:[0-9]*' | cut -d':' -f2)
if ! [[ $inner_exit_code =~ ^[0-9]+$ ]]; then
local number_pattern='^[0-9]+$'
if ! [[ $inner_exit_code =~ $number_pattern ]]; then
inner_exit_code=1
fi
output=$(echo "$output" | sed '$d')
Expand Down
39 changes: 30 additions & 9 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ function runner::load_test_files() {
local filter=$1
shift
local files=("${@}")
local scripts_ids=()
local scripts_ids
scripts_ids=()

for test_file in "${files[@]}"; do
if [[ ! -f $test_file ]]; then
Expand Down Expand Up @@ -113,7 +114,8 @@ function runner::parse_data_provider_args() {
local i
local arg
local encoded_arg
local -a args=()
local args
args=()
# Parse args from the input string into an array, respecting quotes and escapes
for ((i=0; i<${#input}; i++)); do
local char="${input:$i:1}"
Expand Down Expand Up @@ -188,10 +190,18 @@ function runner::call_test_functions() {
break
fi

local provider_data=()
while IFS=" " read -r line; do
provider_data+=("$line")
done <<< "$(helper::get_provider_data "$fn_name" "$script")"
local provider_data
provider_data=()
local provider_output
provider_output="$(helper::get_provider_data "$fn_name" "$script")"
if [[ -n "$provider_output" ]]; then
local line
while IFS=" " read -r line; do
provider_data+=("$line")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable assignments of the form var+=rhs and arr+=(...) don't exist in Bash 3.0. It was introduced in Bash 3.1.

done << EOF
$provider_output
EOF
fi

# No data provider found
if [[ "${#provider_data[@]}" -eq 0 ]]; then
Expand All @@ -202,10 +212,16 @@ function runner::call_test_functions() {

# Execute the test function for each line of data
for data in "${provider_data[@]}"; do
local parsed_data=()
local parsed_data
parsed_data=()
local args_output
args_output="$(runner::parse_data_provider_args "$data")"
local line
while IFS= read -r line; do
parsed_data+=( "$(helper::decode_base64 "${line}")" )
done <<< "$(runner::parse_data_provider_args "$data")"
done << EOF
$args_output
EOF
runner::run_test "$script" "$fn_name" "${parsed_data[@]}"
done
unset fn_name
Expand Down Expand Up @@ -235,7 +251,12 @@ function runner::call_bench_functions() {
fi

for fn_name in "${functions_to_run[@]}"; do
read -r revs its max_ms <<< "$(benchmark::parse_annotations "$fn_name" "$script")"
local annotation_result
annotation_result="$(benchmark::parse_annotations "$fn_name" "$script")"
set -- "$annotation_result"
revs="$1"
its="$2"
max_ms="$3"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work. The "$annotation_result" will not be split.

In the first place, what is the background of rewriting read -r vars... <<< str?

benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms"
unset fn_name
done
Expand Down
7 changes: 4 additions & 3 deletions src/test_doubles.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash

declare -a MOCKED_FUNCTIONS=()
MOCKED_FUNCTIONS=()

function unmock() {
local command=$1
Expand Down Expand Up @@ -95,7 +95,8 @@ function assert_have_been_called_with() {
shift

local index=""
if [[ ${!#} =~ ^[0-9]+$ ]]; then
local number_pattern='^[0-9]+$'
if [[ ${!#} =~ $number_pattern ]]; then
index=${!#}
set -- "${@:1:$#-1}"
fi
Expand All @@ -115,7 +116,7 @@ function assert_have_been_called_with() {
fi

local raw
IFS='|' read -r raw _ <<<"$line"
raw=$(echo "$line" | cut -d'|' -f1)

if [[ "$expected" != "$raw" ]]; then
state::add_assertions_failed
Expand Down
Loading