diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 86835dbb..23f2c194 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7bc31bbd..e59023ec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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. --- @@ -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") @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbde231..9ff90e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bashunit b/bashunit index 953965af..492d3394 100755 --- a/bashunit +++ b/bashunit @@ -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 @@ -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 + IFS=. read -r major _ _ <<< "$current_version" - 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 diff --git a/build.sh b/build.sh index 081a87b7..5ddc84de 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/install.sh b/install.sh index 981b54a0..c9497ba5 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/src/assert.sh b/src/assert.sh index bd9d061b..d971a698 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -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 eval "$cmd" &> /dev/null else "$cmd" &> /dev/null @@ -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:]') ((actual+=additional_new_lines)) fi diff --git a/src/benchmark.sh b/src/benchmark.sh index 32b9eb91..f55a9f2e 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -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 @@ -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) @@ -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}" diff --git a/src/clock.sh b/src/clock.sh index 27aed6cf..f78279e9 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -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") @@ -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 diff --git a/src/helpers.sh b/src/helpers.sh index 6ffe1e29..23c3df7d 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -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" } @@ -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 @@ -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 @@ -269,7 +271,8 @@ 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=() + local provider_data + provider_data=() while IFS=" " read -r line; do provider_data+=("$line") done <<< "$(helper::get_provider_data "$fn_name" "$file")" @@ -295,7 +298,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 @@ -314,7 +318,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 diff --git a/src/main.sh b/src/main.sh index 0f220ad7..ef610625 100644 --- a/src/main.sh +++ b/src/main.sh @@ -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[@]}") @@ -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[@]}") @@ -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') diff --git a/src/runner.sh b/src/runner.sh index 3544e382..07dbddb0 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -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 @@ -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}" @@ -188,7 +190,8 @@ function runner::call_test_functions() { break fi - local provider_data=() + local provider_data + provider_data=() while IFS=" " read -r line; do provider_data+=("$line") done <<< "$(helper::get_provider_data "$fn_name" "$script")" @@ -202,7 +205,8 @@ 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=() while IFS= read -r line; do parsed_data+=( "$(helper::decode_base64 "${line}")" ) done <<< "$(runner::parse_data_provider_args "$data")" @@ -235,7 +239,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" benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" unset fn_name done diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 6ec15523..50207ee5 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -declare -a MOCKED_FUNCTIONS=() +MOCKED_FUNCTIONS=() function unmock() { local command=$1 @@ -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 @@ -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 diff --git a/tests/unit/bash_version_test.sh b/tests/unit/bash_version_test.sh index b82fb561..f1b63ba8 100755 --- a/tests/unit/bash_version_test.sh +++ b/tests/unit/bash_version_test.sh @@ -1,8 +1,14 @@ #!/usr/bin/env bash function test_fail_with_old_bash_version() { - output=$(BASHUNIT_TEST_BASH_VERSION=3.1 ./bashunit --version 2>&1) + output=$(BASHUNIT_TEST_BASH_VERSION=2.9 ./bashunit --version 2>&1) exit_code=$? - assert_contains "Bashunit requires Bash >= 3.2. Current version: 3.1" "$output" + assert_contains "Bashunit requires Bash >= 3.0. Current version: 2.9" "$output" assert_general_error "$output" "" "$exit_code" } + +function test_pass_with_bash_3_0() { + output=$(BASHUNIT_TEST_BASH_VERSION=3.0 ./bashunit --version 2>&1) + exit_code=$? + assert_successful_code "$output" "" "$exit_code" +}