From e374f8c84e1c325bb411ef0890ab3adcf9cd71ad Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 3 Feb 2026 22:19:19 +0100 Subject: [PATCH 01/41] feat: lower minimum bash version requirement from 3.2 to 3.0 - Replace printf -v with command substitution (Bash 3.1 feature) - Replace += operators with explicit concatenation (Bash 3.1 feature) - Store regex patterns in variables for [[ =~ ]] compatibility - Fix empty array access with set -u by using conditional initialization - Update version check, tests, and documentation --- .github/CONTRIBUTING.md | 2 +- .github/copilot-instructions.md | 6 +- .github/workflows/tests-bash-3.0.yml | 58 +++++++++++++++ AGENTS.md | 2 +- CHANGELOG.md | 3 + README.md | 2 +- bashunit | 4 +- docs/installation.md | 2 +- src/assert.sh | 29 ++++---- src/assert_arrays.sh | 10 ++- src/benchmark.sh | 41 +++++++---- src/clock.sh | 18 +++-- src/console_header.sh | 13 +++- src/console_results.sh | 32 ++++---- src/coverage.sh | 72 +++++++++++------- src/doc.sh | 6 +- src/globals.sh | 2 +- src/helpers.sh | 84 ++++++++++++++------- src/learn.sh | 10 ++- src/main.sh | 70 +++++++++++------- src/reports.sh | 10 +-- src/runner.sh | 106 ++++++++++++++++----------- src/state.sh | 4 +- src/str.sh | 10 +-- src/test_doubles.sh | 6 +- tests/unit/bash_version_test.sh | 4 +- 26 files changed, 389 insertions(+), 217 deletions(-) create mode 100644 .github/workflows/tests-bash-3.0.yml 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..4055ec14 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^^)}`, no `printf -v`) - **Follows established module namespacing** patterns ### ✅ Testing (following observed patterns) diff --git a/.github/workflows/tests-bash-3.0.yml b/.github/workflows/tests-bash-3.0.yml new file mode 100644 index 00000000..be914e4d --- /dev/null +++ b/.github/workflows/tests-bash-3.0.yml @@ -0,0 +1,58 @@ +name: Bash 3.0 Compatibility + +on: + pull_request: + push: + branches: + - main + +jobs: + bash-3-0: + name: "Bash 3.0 - Simple Parallel" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Build Bash 3.0 Docker image + run: | + docker build -t bashunit-bash3 -f - . <<'EOF' + FROM debian:bullseye-slim + + RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + ca-certificates \ + bison \ + && rm -rf /var/lib/apt/lists/* + + WORKDIR /tmp + RUN curl -LO https://ftp.gnu.org/gnu/bash/bash-3.0.tar.gz \ + && tar xzf bash-3.0.tar.gz \ + && cd bash-3.0 \ + && curl -fsSL -o support/config.guess 'https://raw.githubusercontent.com/gcc-mirror/gcc/master/config.guess' \ + && curl -fsSL -o support/config.sub 'https://raw.githubusercontent.com/gcc-mirror/gcc/master/config.sub' \ + && chmod +x support/config.guess support/config.sub \ + && ./configure --prefix=/opt/bash-3.0 \ + && make \ + && make install \ + && rm -rf /tmp/bash-3.0* + + WORKDIR /bashunit + CMD ["/opt/bash-3.0/bin/bash", "--version"] + EOF + + - name: Verify Bash 3.0 version + run: | + docker run --rm bashunit-bash3 /opt/bash-3.0/bin/bash --version + + - name: Run tests with Bash 3.0 (simple parallel) + run: | + docker run --rm \ + -v "$(pwd)":/bashunit:ro \ + -w /bashunit \ + bashunit-bash3 \ + /opt/bash-3.0/bin/bash ./bashunit --simple --parallel tests/ diff --git a/AGENTS.md b/AGENTS.md index c19d42da..cc4db65f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Examples must mirror **real** patterns from `./tests/**` exactly: ## Path-Scoped Guidance -- `./src/**`: small, portable functions, namespaced; maintain Bash 3.2+ compatibility +- `./src/**`: small, portable functions, namespaced; maintain Bash 3.0+ compatibility - `./tests/**`: behavior-focused tests using official assertions/doubles; avoid networks/unverified tools - `./.tasks/**`: one file per change (`YYYY-MM-DD-slug.md`); keep AC, test inventory, current red bar, and timestamped Logbook updated - `./adrs/**`: read first; when adding, use template and match existing ADR style diff --git a/CHANGELOG.md b/CHANGELOG.md index d7534802..36b0da9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- Lower minimum Bash version requirement from 3.2 to 3.0 + ### Added - Display test output (stdout/stderr) on failure for runtime errors - Shows captured output in an "Output:" section when tests fail with runtime errors diff --git a/README.md b/README.md index 4bab7005..373cd051 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ You can find the complete documentation for **bashunit** online, including insta ## Requirements -bashunit requires **Bash 3.2** or newer. +bashunit requires **Bash 3.0** or newer. ## Contribute diff --git a/bashunit b/bashunit index 4f547107..715ede57 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 @@ -19,7 +19,7 @@ function _check_bash_version() { local major minor IFS=. read -r major minor _ <<< "$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/docs/installation.md b/docs/installation.md index 2d2708a5..4c23c542 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,7 +7,7 @@ Here, we provide different options that you can use to install **bashunit** in y ## Requirements -bashunit requires **Bash 3.2** or newer. +bashunit requires **Bash 3.0** or newer. ## install.sh diff --git a/src/assert.sh b/src/assert.sh index 690e4bd5..56008cd2 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -219,7 +219,7 @@ function assert_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -263,7 +263,7 @@ function assert_not_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -284,7 +284,7 @@ function assert_matches() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -305,7 +305,7 @@ function assert_not_matches() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -379,16 +379,16 @@ function assert_exec() { fi if $check_stdout; then - expected_desc+=$'\n'"stdout: $expected_stdout" - actual_desc+=$'\n'"stdout: $stdout" + expected_desc="$expected_desc"$'\n'"stdout: $expected_stdout" + actual_desc="$actual_desc"$'\n'"stdout: $stdout" if [[ "$stdout" != "$expected_stdout" ]]; then failed=1 fi fi if $check_stderr; then - expected_desc+=$'\n'"stderr: $expected_stderr" - actual_desc+=$'\n'"stderr: $stderr" + expected_desc="$expected_desc"$'\n'"stderr: $expected_stderr" + actual_desc="$actual_desc"$'\n'"stderr: $stderr" if [[ "$stderr" != "$expected_stderr" ]]; then failed=1 fi @@ -507,7 +507,7 @@ function assert_string_starts_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -547,7 +547,7 @@ function assert_string_ends_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -568,7 +568,7 @@ function assert_string_not_ends_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -665,9 +665,10 @@ function assert_line_count() { bashunit::assert::should_skip && return 0 local expected="$1" - local input_arr=("${@:2}") + # Bash 3.0 compatible array initialization + local input_arr; [[ $# -gt 1 ]] && input_arr=("${@:2}") local input_str - input_str=$(printf '%s\n' "${input_arr[@]}") + input_str=$(printf '%s\n' ${input_arr+"${input_arr[@]}"}) if [ -z "$input_str" ]; then local actual=0 @@ -676,7 +677,7 @@ function assert_line_count() { 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:]') - ((actual+=additional_new_lines)) + actual=$((actual + additional_new_lines)) fi if [[ "$expected" != "$actual" ]]; then diff --git a/src/assert_arrays.sh b/src/assert_arrays.sh index 214db95a..c29662a0 100644 --- a/src/assert_arrays.sh +++ b/src/assert_arrays.sh @@ -10,9 +10,10 @@ function assert_array_contains() { label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift - local actual=("${@}") + # Bash 3.0 compatible array initialization + local actual; [[ $# -gt 0 ]] && actual=("$@") - if ! [[ "${actual[*]}" == *"$expected"* ]]; then + if ! [[ "${actual[*]:-}" == *"$expected"* ]]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to contain" "${expected}" return @@ -30,9 +31,10 @@ function assert_array_not_contains() { local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift - local actual=("$@") + # Bash 3.0 compatible array initialization + local actual; [[ $# -gt 0 ]] && actual=("$@") - if [[ "${actual[*]}" == *"$expected"* ]]; then + if [[ "${actual[*]:-}" == *"$expected"* ]]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to not contain" "${expected}" return diff --git a/src/benchmark.sh b/src/benchmark.sh index 1abd4fcc..70889c65 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -16,21 +16,28 @@ function bashunit::benchmark::parse_annotations() { local annotation annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script") - if [[ $annotation =~ @revs=([0-9]+) ]]; then + # Patterns stored in variables for Bash 3.0 compatibility + 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 @@ -42,11 +49,11 @@ function bashunit::benchmark::parse_annotations() { } function bashunit::benchmark::add_result() { - _BASHUNIT_BENCH_NAMES+=("$1") - _BASHUNIT_BENCH_REVS+=("$2") - _BASHUNIT_BENCH_ITS+=("$3") - _BASHUNIT_BENCH_AVERAGES+=("$4") - _BASHUNIT_BENCH_MAX_MILLIS+=("$5") + _BASHUNIT_BENCH_NAMES[${#_BASHUNIT_BENCH_NAMES[@]}]="$1" + _BASHUNIT_BENCH_REVS[${#_BASHUNIT_BENCH_REVS[@]}]="$2" + _BASHUNIT_BENCH_ITS[${#_BASHUNIT_BENCH_ITS[@]}]="$3" + _BASHUNIT_BENCH_AVERAGES[${#_BASHUNIT_BENCH_AVERAGES[@]}]="$4" + _BASHUNIT_BENCH_MAX_MILLIS[${#_BASHUNIT_BENCH_MAX_MILLIS[@]}]="$5" } # shellcheck disable=SC2155 @@ -55,7 +62,9 @@ function bashunit::benchmark::run_function() { local revs=$2 local its=$3 local max_ms=$4 - local durations=() + # Declare without =() for Bash 3.0 compatibility with set -u + local durations + local durations_count=0 for ((i=1; i<=its; i++)); do local start_time=$(bashunit::clock::now) @@ -67,7 +76,7 @@ function bashunit::benchmark::run_function() { local end_time=$(bashunit::clock::now) local dur_ns=$(bashunit::math::calculate "($end_time - $start_time)") local dur_ms=$(bashunit::math::calculate "$dur_ns / 1000000") - durations+=("$dur_ms") + durations[durations_count]="$dur_ms"; durations_count=$((durations_count + 1)) if bashunit::env::is_bench_mode_enabled; then local label="$(bashunit::helper::normalize_test_function_name "$fn_name")" @@ -129,13 +138,15 @@ function bashunit::benchmark::print_results() { if [[ "$avg" -le "$max_ms" ]]; 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" \ "$_BASHUNIT_COLOR_FAILED" "$padded" "${_BASHUNIT_COLOR_DEFAULT}" diff --git a/src/clock.sh b/src/clock.sh index 2ba3509e..19ccc789 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -4,37 +4,39 @@ _BASHUNIT_CLOCK_NOW_IMPL="" function bashunit::clock::_choose_impl() { local shell_time - local attempts=() + # Use explicit indices for Bash 3.0 compatibility (empty array access fails with set -u) + local attempts_count=0 + local attempts # 1. Try Perl with Time::HiRes - attempts+=("Perl") + attempts[attempts_count]="Perl"; attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_perl && perl -MTime::HiRes -e "" &>/dev/null; then _BASHUNIT_CLOCK_NOW_IMPL="perl" return 0 fi # 2. Try Python 3 with time module - attempts+=("Python") + attempts[attempts_count]="Python"; attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_python; then _BASHUNIT_CLOCK_NOW_IMPL="python" return 0 fi # 3. Try Node.js - attempts+=("Node") + attempts[attempts_count]="Node"; attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_node; then _BASHUNIT_CLOCK_NOW_IMPL="node" return 0 fi # 4. Windows fallback with PowerShell - attempts+=("PowerShell") + attempts[attempts_count]="PowerShell"; attempts_count=$((attempts_count + 1)) if bashunit::check_os::is_windows && bashunit::dependencies::has_powershell; then _BASHUNIT_CLOCK_NOW_IMPL="powershell" return 0 fi # 5. Unix fallback using `date +%s%N` (if not macOS or Alpine) - attempts+=("date") + attempts[attempts_count]="date"; attempts_count=$((attempts_count + 1)) if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then local result result=$(date +%s%N 2>/dev/null) @@ -45,14 +47,14 @@ function bashunit::clock::_choose_impl() { fi # 6. Try using native shell EPOCHREALTIME (if available) - attempts+=("EPOCHREALTIME") + attempts[attempts_count]="EPOCHREALTIME"; attempts_count=$((attempts_count + 1)) if shell_time="$(bashunit::clock::shell_time)"; then _BASHUNIT_CLOCK_NOW_IMPL="shell" return 0 fi # 7. Very last fallback: seconds resolution only - attempts[${#attempts[@]}]="date-seconds" + attempts[attempts_count]="date-seconds"; attempts_count=$((attempts_count + 1)) if date +%s &>/dev/null; then _BASHUNIT_CLOCK_NOW_IMPL="date-seconds" return 0 diff --git a/src/console_header.sh b/src/console_header.sh index cc486935..e93c211c 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -2,13 +2,15 @@ function bashunit::console_header::print_version_with_env() { local filter=${1:-} - local files=("${@:2}") + # Bash 3.0 compatible: check argument count before array access + local files + [[ $# -gt 1 ]] && files=("${@:2}") if ! bashunit::env::is_show_header_enabled; then return fi - bashunit::console_header::print_version "$filter" "${files[@]}" + bashunit::console_header::print_version "$filter" ${files+"${files[@]}"} if bashunit::env::is_dev_mode_enabled; then printf "%sDev log:%s %s\n" "${_BASHUNIT_COLOR_INCOMPLETE}" "${_BASHUNIT_COLOR_DEFAULT}" "$BASHUNIT_DEV_LOG" @@ -21,9 +23,12 @@ function bashunit::console_header::print_version() { shift fi - local files=("$@") + # Bash 3.0 compatible: check argument count before array access + local files + local files_count=$# + [[ $# -gt 0 ]] && files=("$@") local total_tests - if [[ ${#files[@]} -eq 0 ]]; then + if [[ "$files_count" -eq 0 ]]; then total_tests=0 elif bashunit::parallel::is_enabled && bashunit::env::is_simple_output_enabled; then # Skip counting in parallel+simple mode for faster startup diff --git a/src/console_results.sh b/src/console_results.sh index 143ccbfd..a109ff7c 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -29,18 +29,18 @@ function bashunit::console_results::render_result() { local assertions_failed=$_BASHUNIT_ASSERTIONS_FAILED local total_tests=0 - ((total_tests += tests_passed)) || true - ((total_tests += tests_skipped)) || true - ((total_tests += tests_incomplete)) || true - ((total_tests += tests_snapshot)) || true - ((total_tests += tests_failed)) || true + total_tests=$((total_tests + tests_passed)) + total_tests=$((total_tests + tests_skipped)) + total_tests=$((total_tests + tests_incomplete)) + total_tests=$((total_tests + tests_snapshot)) + total_tests=$((total_tests + tests_failed)) local total_assertions=0 - ((total_assertions += assertions_passed)) || true - ((total_assertions += assertions_skipped)) || true - ((total_assertions += assertions_incomplete)) || true - ((total_assertions += assertions_snapshot)) || true - ((total_assertions += assertions_failed)) || true + total_assertions=$((total_assertions + assertions_passed)) + total_assertions=$((total_assertions + assertions_skipped)) + total_assertions=$((total_assertions + assertions_incomplete)) + total_assertions=$((total_assertions + assertions_snapshot)) + total_assertions=$((total_assertions + assertions_failed)) printf "%sTests: %s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" if [[ "$tests_passed" -gt 0 ]] || [[ "$assertions_passed" -gt 0 ]]; then @@ -262,7 +262,7 @@ ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s "${function_name}" "${expected}" "${failure_condition_message}" "${actual}")" if [ -n "$extra_key" ]; then - line+="$(printf "\ + line="$line$(printf "\ ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ "${extra_key}" "${extra_value}")" @@ -290,7 +290,7 @@ function bashunit::console_results::print_failed_snapshot_test() { "$snapshot_file" "$actual_file" 2>/dev/null \ | tail -n +6 | sed "s/^/ /")" - line+="$git_diff_output" + line="$line$git_diff_output" rm "$actual_file" fi @@ -305,7 +305,7 @@ function bashunit::console_results::print_skipped_test() { line="$(printf "${_BASHUNIT_COLOR_SKIPPED}↷ Skipped${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${function_name}")" if [[ -n "$reason" ]]; then - line+="$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${reason}")" + line="$line$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${reason}")" fi bashunit::state::print_line "skipped" "$line" @@ -319,7 +319,7 @@ function bashunit::console_results::print_incomplete_test() { line="$(printf "${_BASHUNIT_COLOR_INCOMPLETE}✒ Incomplete${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${function_name}")" if [[ -n "$pending" ]]; then - line+="$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${pending}")" + line="$line$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${pending}")" fi bashunit::state::print_line "incomplete" "$line" @@ -349,9 +349,9 @@ function bashunit::console_results::print_error_test() { ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT}\n" "${test_name}" "${error}")" if [[ -n "$raw_output" ]] && bashunit::env::is_show_output_on_failure_enabled; then - line+="$(printf " %sOutput:%s\n" "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}")" + line="$line$(printf " %sOutput:%s\n" "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}")" while IFS= read -r output_line; do - line+="$(printf " %s\n" "$output_line")" + line="$line$(printf " %s\n" "$output_line")" done <<< "$raw_output" fi diff --git a/src/coverage.sh b/src/coverage.sh index 34f96cd8..ce31c974 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -6,7 +6,7 @@ _BASHUNIT_COVERAGE_DATA_FILE="${_BASHUNIT_COVERAGE_DATA_FILE:-}" _BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" -# Simple file-based cache for tracked files (Bash 3.2 compatible) +# Simple file-based cache for tracked files (Bash 3.0 compatible) # The tracked cache file stores files that have already been processed _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-}" @@ -19,7 +19,9 @@ _BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" function bashunit::coverage::auto_discover_paths() { local project_root project_root="$(pwd)" - local -a discovered_paths=() + # Bash 3.0 compatible array initialization + local discovered_paths + local discovered_paths_count=0 for test_file in "$@"; do # Extract base name: tests/unit/assert_test.sh -> assert_test.sh @@ -38,12 +40,12 @@ function bashunit::coverage::auto_discover_paths() { [[ "$found_file" == *Test* ]] && continue [[ "$found_file" == *vendor* ]] && continue [[ "$found_file" == *node_modules* ]] && continue - discovered_paths+=("$found_file") + discovered_paths[discovered_paths_count]="$found_file"; discovered_paths_count=$((discovered_paths_count + 1)) done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null) done # Return unique paths, comma-separated - if [[ ${#discovered_paths[@]} -gt 0 ]]; then + if [[ "$discovered_paths_count" -gt 0 ]]; then printf '%s\n' "${discovered_paths[@]}" | sort -u | tr '\n' ',' | sed 's/,$//' fi } @@ -203,7 +205,7 @@ function bashunit::coverage::should_track() { # Skip if tracked files list doesn't exist (trap inherited by child process) [[ -z "$_BASHUNIT_COVERAGE_TRACKED_FILES" ]] && return 1 - # Check file-based cache for previous decision (Bash 3.2 compatible) + # Check file-based cache for previous decision (Bash 3.0 compatible) # Cache format: "file:0" for excluded, "file:1" for tracked # In parallel mode, use per-process cache to avoid race conditions local cache_file="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" @@ -338,7 +340,7 @@ function bashunit::coverage::aggregate_parallel() { # Matches: function foo() { OR foo() { OR function foo() OR foo() # Does NOT match single-line functions with body: function foo() { echo "hi"; } _BASHUNIT_COVERAGE_FUNC_PATTERN='^[[:space:]]*(function[[:space:]]+)?' -_BASHUNIT_COVERAGE_FUNC_PATTERN+='[a-zA-Z_][a-zA-Z0-9_:]*[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*$' +_BASHUNIT_COVERAGE_FUNC_PATTERN="${_BASHUNIT_COVERAGE_FUNC_PATTERN}"'[a-zA-Z_][a-zA-Z0-9_:]*[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*$' # Check if a line is executable (used by get_executable_lines and report_lcov) # Arguments: line content, line number @@ -363,13 +365,17 @@ function bashunit::coverage::is_executable_line() { [[ "$line" =~ ^[[:space:]]*[\{\}][[:space:]]*$ ]] && return 1 # Skip control flow keywords (then, else, fi, do, done, esac, in, ;;, ;&, ;;&) - [[ "$line" =~ ^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$ ]] && return 1 + # Pattern stored in variable for Bash 3.0 compatibility + local _ctrl_pattern='^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$' + [[ "$line" =~ $_ctrl_pattern ]] && return 1 # Skip case patterns like "--option)" or "*)" - [[ "$line" =~ ^[[:space:]]*[^\)]+\)[[:space:]]*$ ]] && return 1 + local _case_pattern='^[[:space:]]*[^\)]+\)[[:space:]]*$' + [[ "$line" =~ $_case_pattern ]] && return 1 # Skip standalone ) for arrays/subshells - [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]] && return 1 + local _paren_pattern='^[[:space:]]*\)[[:space:]]*(#.*)?$' + [[ "$line" =~ $_paren_pattern ]] && return 1 return 0 } @@ -487,9 +493,12 @@ function bashunit::coverage::extract_functions() { local fn_name="" # Match: function name() or function name { - if [[ "$line" =~ ^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$ ]]; then + # Patterns stored in variables for Bash 3.0 compatibility + local _fn_pattern1='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$' + local _fn_pattern2='^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$' + if [[ "$line" =~ $_fn_pattern1 ]]; then fn_name="${BASH_REMATCH[2]}" - elif [[ "$line" =~ ^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$ ]]; then + elif [[ "$line" =~ $_fn_pattern2 ]]; then fn_name="${BASH_REMATCH[2]}" fi @@ -583,8 +592,8 @@ function bashunit::coverage::get_percentage() { executable=$(bashunit::coverage::get_executable_lines "$file") hit=$(bashunit::coverage::get_hit_lines "$file") - ((total_executable += executable)) - ((total_hit += hit)) + total_executable=$((total_executable + executable)) + total_hit=$((total_hit + hit)) done < <(bashunit::coverage::get_tracked_files) bashunit::coverage::calculate_percentage "$total_hit" "$total_executable" @@ -613,8 +622,8 @@ function bashunit::coverage::report_text() { pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") class=$(bashunit::coverage::get_coverage_class "$pct") - ((total_executable += executable)) - ((total_hit += hit)) + total_executable=$((total_executable + executable)) + total_hit=$((total_hit + hit)) # Determine color based on class local color="" reset="" @@ -756,7 +765,9 @@ function bashunit::coverage::report_html() { # Collect file data for index local total_executable=0 local total_hit=0 - local file_data=() + # Declare without =() for Bash 3.0 compatibility with set -u + local file_data + local file_data_count=0 while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue @@ -766,14 +777,14 @@ function bashunit::coverage::report_html() { hit=$(bashunit::coverage::get_hit_lines "$file") pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") - ((total_executable += executable)) - ((total_hit += hit)) + total_executable=$((total_executable + executable)) + total_hit=$((total_hit + hit)) local display_file="${file#"$(pwd)"/}" local safe_filename safe_filename=$(bashunit::coverage::path_to_filename "$file") - file_data+=("$display_file|$hit|$executable|$pct|$safe_filename") + file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename"; file_data_count=$((file_data_count + 1)) # Generate individual file HTML bashunit::coverage::generate_file_html "$file" "$output_dir/files/${safe_filename}.html" @@ -806,13 +817,16 @@ function bashunit::coverage::generate_index_html() { local tests_passed="$6" local tests_failed="$7" shift 7 - local file_data=() - [[ $# -gt 0 ]] && file_data=("$@") + # Handle array passed as arguments - Bash 3.0 compatible + local file_data + local file_count=0 + if [[ $# -gt 0 ]]; then + file_data=("$@") + file_count=$# + fi # Calculate uncovered lines and file count local total_uncovered=$((total_executable - total_hit)) - local file_count=0 - [[ ${#file_data[@]} -gt 0 ]] && file_count=${#file_data[@]} # Calculate gauge stroke offset (440 is full circle circumference) local gauge_offset=$((440 - (440 * total_pct / 100))) @@ -1161,7 +1175,8 @@ function bashunit::coverage::generate_file_html() { local uncovered=$((executable - hit)) # Pre-load all line hits into indexed array (performance optimization) - local -a hits_by_line=() + # Bash 3.0 compatible: declare without =() + local hits_by_line local _ln _cnt while IFS=: read -r _ln _cnt; do hits_by_line[_ln]=$_cnt @@ -1169,8 +1184,9 @@ function bashunit::coverage::generate_file_html() { # Pre-load test hits data into indexed array (for tooltips) # Index: line number, Value: newline-separated list of "test_file:test_function" - # Using indexed array for Bash 3.2 compatibility (no associative arrays) - local -a tests_by_line=() + # Using indexed array for Bash 3.0 compatibility (no associative arrays) + # Bash 3.0 compatible: declare without =() + local tests_by_line local _line_and_test while IFS= read -r _line_and_test; do [[ -z "$_line_and_test" ]] && continue @@ -1509,9 +1525,9 @@ EOF [[ -z "$test_file" ]] && continue local short_file short_file=$(basename "$test_file") - tooltip_html+="
  • ${short_file}:${test_fn}
  • " + tooltip_html="$tooltip_html
  • ${short_file}:${test_fn}
  • " done <<< "$test_info" - tooltip_html+="" + tooltip_html="$tooltip_html" hits_display="${hits}×${tooltip_html}" else hits_display="${hits}×" diff --git a/src/doc.sh b/src/doc.sh index 803bb9a6..7010d0cd 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -16,8 +16,10 @@ function bashunit::doc::print_asserts() { local fn="" local should_print=0 + # Pattern stored in variable for Bash 3.0 compatibility + local _doc_pattern='^## ([A-Za-z0-9_]+)' while IFS='' read -r line || [[ -n "$line" ]]; do - if [[ $line =~ ^##\ ([A-Za-z0-9_]+) ]]; then + if [[ $line =~ $_doc_pattern ]]; then fn="${BASH_REMATCH[1]}" if [[ -z "$filter" || "$fn" == *"$filter"* ]]; then should_print=1 @@ -42,7 +44,7 @@ function bashunit::doc::print_asserts() { # Remove markdown link brackets and anchor tags line="${line//[\[\]]/}" line="$(sed -E 's/ *\(#[-a-z0-9]+\)//g' <<< "$line")" - docstring+="$line"$'\n' + docstring="$docstring$line"$'\n' fi done <<< "$(bashunit::doc::get_embedded_docs)" } diff --git a/src/globals.sh b/src/globals.sh index 336ce636..fdf12946 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -32,7 +32,7 @@ function bashunit::random_str() { local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local str='' for (( i=0; i/dev/null && [[ ${#args[@]} -gt 0 ]]; then - # Successfully parsed - remove sentinel if present - local last_idx=$((${#args[@]} - 1)) - if [[ -z "${args[$last_idx]}" ]]; then - unset 'args[$last_idx]' + if [[ "$has_metachar" == false ]] && eval "args=($input)" 2>/dev/null; then + # Check if args has elements after eval + args_count=0 + for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done + if [[ "$args_count" -gt 0 ]]; then + # Successfully parsed - remove sentinel if present + local last_idx=$((args_count - 1)) + if [[ -z "${args[$last_idx]}" ]]; then + unset 'args[$last_idx]' + fi + # Print args and return early + for arg in "${args[@]}"; do + encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" + printf '%s\n' "$encoded_arg" + done + return fi - # Print args and return early - for arg in "${args[@]}"; do - encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" - printf '%s\n' "$encoded_arg" - done - return fi # Fallback: parse args from the input string into an array, respecting quotes and escapes @@ -224,9 +237,9 @@ function bashunit::runner::parse_data_provider_args() { local char="${input:$i:1}" if [ "$escaped" = true ]; then case "$char" in - t) current_arg+=$'\t' ;; - n) current_arg+=$'\n' ;; - *) current_arg+="$char" ;; + t) current_arg="$current_arg"$'\t' ;; + n) current_arg="$current_arg"$'\n' ;; + *) current_arg="$current_arg$char" ;; esac escaped=false elif [ "$char" = "\\" ]; then @@ -241,7 +254,7 @@ function bashunit::runner::parse_data_provider_args() { # Skip the $ i=$((i + 1)) else - current_arg+="$char" + current_arg="$current_arg$char" fi ;; "'" | '"') @@ -251,33 +264,34 @@ function bashunit::runner::parse_data_provider_args() { " " | $'\t') # Only add non-empty arguments to avoid duplicates from consecutive separators if [[ -n "$current_arg" ]]; then - args+=("$current_arg") + args[args_count]="$current_arg"; args_count=$((args_count + 1)) fi current_arg="" ;; *) - current_arg+="$char" + current_arg="$current_arg$char" ;; esac elif [ "$char" = "$quote_char" ]; then in_quotes=false quote_char="" else - current_arg+="$char" + current_arg="$current_arg$char" fi done - args+=("$current_arg") + args[args_count]="$current_arg"; args_count=$((args_count + 1)) # Remove all trailing empty strings - while [[ ${#args[@]} -gt 0 ]]; do - local last_idx=$((${#args[@]} - 1)) + while [[ "$args_count" -gt 0 ]]; do + local last_idx=$((args_count - 1)) if [[ -z "${args[$last_idx]}" ]]; then unset 'args[$last_idx]' + args_count=$((args_count - 1)) else break fi done # Print one arg per line to stdout, base64-encoded to preserve newlines in the data - for arg in "${args[@]+"${args[@]}"}"; do + for arg in ${args+"${args[@]}"}; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done @@ -305,13 +319,15 @@ function bashunit::runner::call_test_functions() { break fi - local provider_data=() + # Declare without =() for Bash 3.0 compatibility with set -u + local provider_data + local provider_data_count=0 while IFS=" " read -r line; do - provider_data+=("$line") + 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[@]}" -eq 0 ]]; then + if [[ "$provider_data_count" -eq 0 ]]; then bashunit::runner::run_test "$script" "$fn_name" unset fn_name continue @@ -319,9 +335,11 @@ function bashunit::runner::call_test_functions() { # Execute the test function for each line of data for data in "${provider_data[@]}"; do - local parsed_data=() + # Declare without =() for Bash 3.0 compatibility with set -u + local parsed_data + local parsed_data_count=0 while IFS= read -r line; do - parsed_data+=( "$(bashunit::helper::decode_base64 "${line}")" ) + parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")"; parsed_data_count=$((parsed_data_count + 1)) done <<< "$(bashunit::runner::parse_data_provider_args "$data")" bashunit::runner::run_test "$script" "$fn_name" "${parsed_data[@]}" done @@ -680,10 +698,12 @@ function bashunit::runner::parse_result() { shift local execution_result=$1 shift - local args=("$@") + # Bash 3.0 compatible array initialization + local args + [[ $# -gt 0 ]] && args=("$@") if bashunit::parallel::is_enabled; then - bashunit::runner::parse_result_parallel "$fn_name" "$execution_result" "${args[@]}" + bashunit::runner::parse_result_parallel "$fn_name" "$execution_result" ${args+"${args[@]}"} else bashunit::runner::parse_result_sync "$fn_name" "$execution_result" fi @@ -694,7 +714,9 @@ function bashunit::runner::parse_result_parallel() { shift local execution_result=$1 shift - local args=("$@") + # Bash 3.0 compatible array initialization + local args + [[ $# -gt 0 ]] && args=("$@") local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/$(basename "$test_file" .sh)" mkdir -p "$test_suite_dir" @@ -751,12 +773,12 @@ function bashunit::runner::parse_result_sync() { bashunit::internal_log "[SYNC]" "fn_name:$fn_name" "execution_result:$execution_result" - ((_BASHUNIT_ASSERTIONS_PASSED += assertions_passed)) || true - ((_BASHUNIT_ASSERTIONS_FAILED += assertions_failed)) || true - ((_BASHUNIT_ASSERTIONS_SKIPPED += assertions_skipped)) || true - ((_BASHUNIT_ASSERTIONS_INCOMPLETE += assertions_incomplete)) || true - ((_BASHUNIT_ASSERTIONS_SNAPSHOT += assertions_snapshot)) || true - ((_BASHUNIT_TEST_EXIT_CODE += test_exit_code)) || true + _BASHUNIT_ASSERTIONS_PASSED=$((_BASHUNIT_ASSERTIONS_PASSED + assertions_passed)) + _BASHUNIT_ASSERTIONS_FAILED=$((_BASHUNIT_ASSERTIONS_FAILED + assertions_failed)) + _BASHUNIT_ASSERTIONS_SKIPPED=$((_BASHUNIT_ASSERTIONS_SKIPPED + assertions_skipped)) + _BASHUNIT_ASSERTIONS_INCOMPLETE=$((_BASHUNIT_ASSERTIONS_INCOMPLETE + assertions_incomplete)) + _BASHUNIT_ASSERTIONS_SNAPSHOT=$((_BASHUNIT_ASSERTIONS_SNAPSHOT + assertions_snapshot)) + _BASHUNIT_TEST_EXIT_CODE=$((_BASHUNIT_TEST_EXIT_CODE + test_exit_code)) bashunit::internal_log "result_summary" \ "failed:$assertions_failed" \ diff --git a/src/state.sh b/src/state.sh index b869e83c..ef5ce23b 100644 --- a/src/state.sh +++ b/src/state.sh @@ -126,7 +126,7 @@ function bashunit::state::set_file_with_duplicated_function_names() { } function bashunit::state::add_test_output() { - _BASHUNIT_TEST_OUTPUT+="$1" + _BASHUNIT_TEST_OUTPUT="$_BASHUNIT_TEST_OUTPUT$1" } function bashunit::state::get_test_exit_code() { @@ -253,7 +253,7 @@ function bashunit::state::calculate_total_assertions() { numbers=$(echo "$input" | grep -oE '##ASSERTIONS_\w+=[0-9]+' | grep -oE '[0-9]+') for number in $numbers; do - ((total += number)) + total=$((total + number)) done echo $total diff --git a/src/str.sh b/src/str.sh index a30c919a..d0c8981b 100644 --- a/src/str.sh +++ b/src/str.sh @@ -39,14 +39,14 @@ function bashunit::str::rpad() { # If the current character is part of an ANSI sequence, skip it and copy it if [[ "$original_char" == $'\x1b' ]]; then while [[ "${left_text:$j:1}" != "m" && $j -lt ${#left_text} ]]; do - result_left_text+="${left_text:$j:1}" + result_left_text="$result_left_text${left_text:$j:1}" ((j++)) done - result_left_text+="${left_text:$j:1}" # Append the final 'm' + result_left_text="$result_left_text${left_text:$j:1}" # Append the final 'm' ((j++)) elif [[ "$char" == "$original_char" ]]; then # Match the actual character - result_left_text+="$char" + result_left_text="$result_left_text$char" ((i++)) ((j++)) else @@ -56,13 +56,13 @@ function bashunit::str::rpad() { local remaining_space if $is_truncated ; then - result_left_text+="..." + result_left_text="$result_left_text..." # 1: due to a blank space # 3: due to the appended ... remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3)) else # Copy any remaining characters after the truncation point - result_left_text+="${left_text:$j}" + result_left_text="$result_left_text${left_text:$j}" remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1)) fi diff --git a/src/test_doubles.sh b/src/test_doubles.sh index fa199b2f..a408435d 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -34,7 +34,7 @@ function bashunit::mock() { export -f "${command?}" - _BASHUNIT_MOCKED_FUNCTIONS+=("$command") + _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } function bashunit::spy() { @@ -56,7 +56,7 @@ function bashunit::spy() { local serialized=\"\" local arg for arg in \"\$@\"; do - serialized+=\"\$(printf '%q' \"\$arg\")$'\\x1f'\" + serialized=\"\$serialized\$(printf '%q' \"\$arg\")$'\\x1f'\" done serialized=\${serialized%$'\\x1f'} printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' @@ -68,7 +68,7 @@ function bashunit::spy() { export -f "${command?}" - _BASHUNIT_MOCKED_FUNCTIONS+=("$command") + _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } function assert_have_been_called() { diff --git a/tests/unit/bash_version_test.sh b/tests/unit/bash_version_test.sh index 14ef4806..22180ec0 100755 --- a/tests/unit/bash_version_test.sh +++ b/tests/unit/bash_version_test.sh @@ -3,7 +3,7 @@ function test_fail_with_old_bash_version() { local output local exit_code=0 - output=$(BASHUNIT_TEST_BASH_VERSION=3.1 ./bashunit --version 2>&1) || exit_code=$? - assert_contains "Bashunit requires Bash >= 3.2. Current version: 3.1" "$output" + output=$(BASHUNIT_TEST_BASH_VERSION=2.05 ./bashunit --version 2>&1) || exit_code=$? + assert_contains "Bashunit requires Bash >= 3.0. Current version: 2.05" "$output" assert_general_error "$output" "" "$exit_code" } From 510070248ebc22280085e067914d368e1dbb9fdd Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 20:04:00 +0100 Subject: [PATCH 02/41] fix: complete Bash 3.0 compatibility fixes --- src/console_header.sh | 16 ++----- src/env.sh | 4 +- src/globals.sh | 16 +++++-- src/helpers.sh | 93 +++++++++++++++++--------------------- src/main.sh | 59 ++++++++++++++++-------- src/parallel.sh | 4 +- src/runner.sh | 62 ++++++++++++++++++------- tests/unit/assert_test.sh | 12 +++-- tests/unit/helpers_test.sh | 19 +++++--- 9 files changed, 172 insertions(+), 113 deletions(-) diff --git a/src/console_header.sh b/src/console_header.sh index e93c211c..e1be9ecc 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -2,15 +2,13 @@ function bashunit::console_header::print_version_with_env() { local filter=${1:-} - # Bash 3.0 compatible: check argument count before array access - local files - [[ $# -gt 1 ]] && files=("${@:2}") + shift || true if ! bashunit::env::is_show_header_enabled; then return fi - bashunit::console_header::print_version "$filter" ${files+"${files[@]}"} + bashunit::console_header::print_version "$filter" "$@" if bashunit::env::is_dev_mode_enabled; then printf "%sDev log:%s %s\n" "${_BASHUNIT_COLOR_INCOMPLETE}" "${_BASHUNIT_COLOR_DEFAULT}" "$BASHUNIT_DEV_LOG" @@ -19,14 +17,10 @@ function bashunit::console_header::print_version_with_env() { function bashunit::console_header::print_version() { local filter=${1:-} - if [[ -n "$filter" ]]; then - shift - fi + shift || true - # Bash 3.0 compatible: check argument count before array access - local files + # Bash 3.0 compatible: check argument count after shift local files_count=$# - [[ $# -gt 0 ]] && files=("$@") local total_tests if [[ "$files_count" -eq 0 ]]; then total_tests=0 @@ -34,7 +28,7 @@ function bashunit::console_header::print_version() { # Skip counting in parallel+simple mode for faster startup total_tests=0 else - total_tests=$(bashunit::helper::find_total_tests "$filter" "${files[@]}") + total_tests=$(bashunit::helper::find_total_tests "$filter" "$@") fi if bashunit::env::is_header_ascii_art_enabled; then diff --git a/src/env.sh b/src/env.sh index aed4a663..0ad0b3a7 100644 --- a/src/env.sh +++ b/src/env.sh @@ -214,7 +214,9 @@ function bashunit::env::find_terminal_width() { function bashunit::env::print_verbose() { bashunit::internal_log "Printing verbose environment variables" - local keys=( + # Bash 3.0 compatible: separate declaration and assignment for arrays + local keys + keys=( "BASHUNIT_DEFAULT_PATH" "BASHUNIT_DEV_LOG" "BASHUNIT_BOOTSTRAP" diff --git a/src/globals.sh b/src/globals.sh index fdf12946..3c4ca2ab 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -118,11 +118,21 @@ function bashunit::data_set() { for arg in "$@"; do if [ "$first" = true ]; then - printf '%q' "$arg" + # Bash 3.0 compatible: printf '%q' "" produces nothing in Bash 3.0 + if [[ -z "$arg" ]]; then + printf "''" + else + printf '%q' "$arg" + fi first=false else - printf ' %q' "$arg" + if [[ -z "$arg" ]]; then + printf " ''" + else + printf ' %q' "$arg" + fi fi done - printf ' %q\n' "" + # Sentinel empty string at end + printf " ''\n" } diff --git a/src/helpers.sh b/src/helpers.sh index 45045e52..512387a2 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -113,6 +113,12 @@ function bashunit::helper::interpolate_function_name() { function bashunit::helper::encode_base64() { local value="$1" + # Handle empty string specially - base64 of "" is "", which gets lost in line parsing + if [[ -z "$value" ]]; then + printf '%s' "_BASHUNIT_EMPTY_" + return + fi + if command -v base64 >/dev/null; then printf '%s' "$value" | base64 -w 0 2>/dev/null || printf '%s' "$value" | base64 | tr -d '\n' else @@ -123,6 +129,12 @@ function bashunit::helper::encode_base64() { function bashunit::helper::decode_base64() { local value="$1" + # Handle empty string marker + if [[ "$value" == "_BASHUNIT_EMPTY_" ]]; then + printf '' + return + fi + if command -v base64 >/dev/null; then printf '%s' "$value" | base64 -d else @@ -213,8 +225,10 @@ function bashunit::helper::find_files_recursive() { local path="${1%%/}" local pattern="${2:-*[tT]est.sh}" + # Bash 3.0 compatible: store regex in variable for =~ operator + local test_regex='\[tT\]est\.sh$' local alt_pattern="" - if [[ $pattern == *test.sh ]] || [[ $pattern =~ \[tT\]est\.sh$ ]]; then + if [[ $pattern == *test.sh ]] || [[ $pattern =~ $test_regex ]]; then alt_pattern="${pattern%.sh}.bash" fi @@ -299,12 +313,9 @@ function bashunit::helper::get_latest_tag() { function bashunit::helper::find_total_tests() { local filter=${1:-} - # Bash 3.0 compatible array initialization - local files - local files_count=$(($# - 1)) - [[ $# -gt 1 ]] && files=("${@:2}") + shift || true - if [[ "$files_count" -le 0 ]]; then + if [[ $# -eq 0 ]]; then echo 0 return fi @@ -312,7 +323,7 @@ function bashunit::helper::find_total_tests() { local total_count=0 local file - for file in "${files[@]}"; do + for file in "$@"; do if [[ ! -f "$file" ]]; then continue fi @@ -330,13 +341,17 @@ function bashunit::helper::find_total_tests() { if [[ -n "$filtered_functions" ]]; then # shellcheck disable=SC2206 # shellcheck disable=SC2207 - local functions_to_run=($filtered_functions) + # Bash 3.0 compatible: separate declaration and assignment for arrays + local functions_to_run + functions_to_run=($filtered_functions) for fn_name in "${functions_to_run[@]}"; do # Declare without =() for Bash 3.0 compatibility with set -u local provider_data local provider_data_count=0 while IFS=" " read -r line; do - provider_data[provider_data_count]="$line"; provider_data_count=$((provider_data_count + 1)) + [[ -z "$line" ]] && continue + provider_data[provider_data_count]="$line" + provider_data_count=$((provider_data_count + 1)) done <<< "$(bashunit::helper::get_provider_data "$fn_name" "$file")" if [[ "$provider_data_count" -eq 0 ]]; then @@ -357,56 +372,32 @@ function bashunit::helper::find_total_tests() { } function bashunit::helper::load_test_files() { - local filter=$1 - # Bash 3.0 compatible array initialization - local files - local files_count=$(($# - 1)) - [[ $# -gt 1 ]] && files=("${@:2}") - - # Declare without =() for Bash 3.0 compatibility with set -u - local test_files - local test_files_count=0 - - if [[ "$files_count" -le 0 ]]; then - if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then - while IFS='' read -r line; do - test_files[test_files_count]="$line"; test_files_count=$((test_files_count + 1)) - done < <(bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH") + local filter="${1:-}" + shift || true + # Bash 3.0 compatible: use $# after shift to check for files + local has_files=$# + + if [[ "$has_files" -eq 0 ]]; then + if [[ -n "${BASHUNIT_DEFAULT_PATH:-}" ]]; then + bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" fi else - test_files=("${files[@]}") - test_files_count="$files_count" - fi - - if [[ "$test_files_count" -gt 0 ]]; then - printf "%s\n" "${test_files[@]}" + printf "%s\n" "$@" fi } function bashunit::helper::load_bench_files() { - local filter=$1 - # Bash 3.0 compatible array initialization - local files - local files_count=$(($# - 1)) - [[ $# -gt 1 ]] && files=("${@:2}") - - # Declare without =() for Bash 3.0 compatibility with set -u - local bench_files - local bench_files_count=0 - - if [[ "$files_count" -le 0 ]]; then - if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then - while IFS='' read -r line; do - bench_files[bench_files_count]="$line"; bench_files_count=$((bench_files_count + 1)) - done < <(bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" '*[bB]ench.sh') + local filter="${1:-}" + shift || true + # Bash 3.0 compatible: use $# after shift to check for files + local has_files=$# + + if [[ "$has_files" -eq 0 ]]; then + if [[ -n "${BASHUNIT_DEFAULT_PATH:-}" ]]; then + bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" '*[bB]ench.sh' fi else - bench_files=("${files[@]}") - bench_files_count="$files_count" - fi - - if [[ "$bench_files_count" -gt 0 ]]; then - printf "%s\n" "${bench_files[@]}" + printf "%s\n" "$@" fi } diff --git a/src/main.sh b/src/main.sh index 267d35e0..5d7ba37c 100644 --- a/src/main.sh +++ b/src/main.sh @@ -54,8 +54,12 @@ function bashunit::main::cmd_test() { if [[ "$boot_args" != "$2" ]]; then export BASHUNIT_BOOTSTRAP_ARGS="$boot_args" fi + # Export all variables from the env file so they're available in subshells + # (e.g., process substitution used in load_test_files) + set -o allexport # shellcheck disable=SC1090,SC2086 source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} + set +o allexport shift ;; --log-junit) @@ -237,9 +241,15 @@ function bashunit::main::cmd_test() { # not tracking code coverage. This also prevents issues when parent bashunit # runs with coverage and calls subprocess bashunit with -a flag. export BASHUNIT_COVERAGE=false - bashunit::main::exec_assert "$assert_fn" "${args[@]}" + bashunit::main::exec_assert "$assert_fn" ${args+"${args[@]}"} else - bashunit::main::exec_tests "$filter" "${args[@]}" + # Bash 3.0 compatible: only pass args if we have files + # (local args without =() creates a scalar, not an empty array) + if [[ "$args_count" -gt 0 ]]; then + bashunit::main::exec_tests "$filter" "${args[@]}" + else + bashunit::main::exec_tests "$filter" + fi fi } @@ -276,8 +286,12 @@ function bashunit::main::cmd_bench() { if [[ "$boot_args" != "$2" ]]; then export BASHUNIT_BOOTSTRAP_ARGS="$boot_args" fi + # Export all variables from the env file so they're available in subshells + # (e.g., process substitution used in load_test_files) + set -o allexport # shellcheck disable=SC1090,SC2086 source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} + set +o allexport shift ;; -vvv|--verbose) @@ -319,7 +333,12 @@ function bashunit::main::cmd_bench() { set +euo pipefail - bashunit::main::exec_benchmarks "$filter" "${args[@]}" + # Bash 3.0 compatible: only pass args if we have files + if [[ "$args_count" -gt 0 ]]; then + bashunit::main::exec_benchmarks "$filter" "${args[@]}" + else + bashunit::main::exec_benchmarks "$filter" + fi } ############################# @@ -436,20 +455,21 @@ function bashunit::main::cmd_assert() { ############################# function bashunit::main::exec_tests() { local filter=$1 - # Bash 3.0 compatible array initialization - local files - [[ $# -gt 1 ]] && files=("${@:2}") + shift - # Declare without =() for Bash 3.0 compatibility with set -u + # Bash 3.0 compatible: collect files into array local test_files local test_files_count=0 - while IFS= read -r line; do - test_files[test_files_count]="$line"; test_files_count=$((test_files_count + 1)) - done < <(bashunit::helper::load_test_files "$filter" ${files+"${files[@]}"}) + local _line + while IFS= read -r _line; do + [[ -z "$_line" ]] && continue + test_files[test_files_count]="$_line" + test_files_count=$((test_files_count + 1)) + done < <(bashunit::helper::load_test_files "$filter" "$@") bashunit::internal_log "exec_tests" "filter:$filter" "files:${test_files[*]:-}" - if [[ "$test_files_count" -eq 0 || -z "${test_files[0]:-}" ]]; then + if [[ "$test_files_count" -eq 0 ]]; then printf "%sError: At least one file path is required.%s\n" "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::console_header::print_help exit 1 @@ -545,20 +565,21 @@ function bashunit::main::exec_tests() { function bashunit::main::exec_benchmarks() { local filter=$1 - # Bash 3.0 compatible array initialization - local files - [[ $# -gt 1 ]] && files=("${@:2}") + shift - # Declare without =() for Bash 3.0 compatibility with set -u + # Bash 3.0 compatible: collect files into array local bench_files local bench_files_count=0 - while IFS= read -r line; do - bench_files[bench_files_count]="$line"; bench_files_count=$((bench_files_count + 1)) - done < <(bashunit::helper::load_bench_files "$filter" ${files+"${files[@]}"}) + local _line + while IFS= read -r _line; do + [[ -z "$_line" ]] && continue + bench_files[bench_files_count]="$_line" + bench_files_count=$((bench_files_count + 1)) + done < <(bashunit::helper::load_bench_files "$filter" "$@") bashunit::internal_log "exec_benchmarks" "filter:$filter" "files:${bench_files[*]:-}" - if [[ "$bench_files_count" -eq 0 || -z "${bench_files[0]:-}" ]]; then + if [[ "$bench_files_count" -eq 0 ]]; then printf "%sError: At least one file path is required.%s\n" "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::console_header::print_help exit 1 diff --git a/src/parallel.sh b/src/parallel.sh index cf172339..191d47c7 100755 --- a/src/parallel.sh +++ b/src/parallel.sh @@ -13,7 +13,9 @@ function bashunit::parallel::aggregate_test_results() { for script_dir in "$temp_dir_parallel_test_suite"/*; do shopt -s nullglob - local result_files=("$script_dir"/*.result) + # Bash 3.0 compatible: separate declaration and assignment for arrays + local result_files + result_files=("$script_dir"/*.result) shopt -u nullglob if [ ${#result_files[@]} -eq 0 ]; then diff --git a/src/runner.sh b/src/runner.sh index beef1c93..b7c8eeb3 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -65,7 +65,9 @@ function bashunit::runner::load_test_files() { filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") if [[ -n "$filtered_functions" ]]; then # shellcheck disable=SC2206 - local functions_to_run=($filtered_functions) + # Bash 3.0 compatible: separate declaration and assignment for arrays + local functions_to_run + functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed @@ -132,7 +134,9 @@ function bashunit::runner::load_bench_files() { filtered_functions=$(bashunit::helper::get_functions_to_run "bench" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") if [[ -n "$filtered_functions" ]]; then # shellcheck disable=SC2206 - local functions_to_run=($filtered_functions) + # Bash 3.0 compatible: separate declaration and assignment for arrays + local functions_to_run + functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed @@ -197,6 +201,7 @@ function bashunit::runner::parse_data_provider_args() { local input="$1" local current_arg="" local in_quotes=false + local had_quotes=false # Track if arg was quoted (to preserve empty quoted strings) local quote_char="" local escaped=false local i @@ -250,6 +255,7 @@ function bashunit::runner::parse_data_provider_args() { # Handle $'...' syntax if [[ "${input:$i:2}" == "$'" ]]; then in_quotes=true + had_quotes=true quote_char="'" # Skip the $ i=$((i + 1)) @@ -259,14 +265,16 @@ function bashunit::runner::parse_data_provider_args() { ;; "'" | '"') in_quotes=true + had_quotes=true quote_char="$char" ;; " " | $'\t') - # Only add non-empty arguments to avoid duplicates from consecutive separators - if [[ -n "$current_arg" ]]; then + # Add if non-empty OR if was quoted (to preserve empty quoted strings like '') + if [[ -n "$current_arg" || "$had_quotes" == true ]]; then args[args_count]="$current_arg"; args_count=$((args_count + 1)) fi current_arg="" + had_quotes=false ;; *) current_arg="$current_arg$char" @@ -305,10 +313,17 @@ function bashunit::runner::call_test_functions() { local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run \ "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - # shellcheck disable=SC2207 - local functions_to_run=($(bashunit::runner::functions_for_script "$script" "$filtered_functions")) - - if [[ "${#functions_to_run[@]}" -le 0 ]]; then + # Bash 3.0 compatible array initialization + local functions_to_run + local functions_to_run_count=0 + local _fn + while IFS= read -r _fn; do + [[ -z "$_fn" ]] && continue + functions_to_run[functions_to_run_count]="$_fn" + functions_to_run_count=$((functions_to_run_count + 1)) + done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") + + if [[ "$functions_to_run_count" -le 0 ]]; then return fi @@ -319,11 +334,14 @@ function bashunit::runner::call_test_functions() { break fi - # Declare without =() for Bash 3.0 compatibility with set -u + # Bash 3.0 compatible: unset before redeclaring to clear previous iteration's data + unset provider_data local provider_data local provider_data_count=0 while IFS=" " read -r line; do - provider_data[provider_data_count]="$line"; provider_data_count=$((provider_data_count + 1)) + [[ -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 @@ -335,13 +353,16 @@ function bashunit::runner::call_test_functions() { # Execute the test function for each line of data for data in "${provider_data[@]}"; do - # Declare without =() for Bash 3.0 compatibility with set -u + # Bash 3.0 compatible: unset before redeclaring to clear previous iteration's data + unset parsed_data local parsed_data local parsed_data_count=0 while IFS= read -r line; do - parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")"; parsed_data_count=$((parsed_data_count + 1)) + [[ -z "$line" ]] && continue + parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" + parsed_data_count=$((parsed_data_count + 1)) done <<< "$(bashunit::runner::parse_data_provider_args "$data")" - bashunit::runner::run_test "$script" "$fn_name" "${parsed_data[@]}" + bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} done unset fn_name done @@ -356,10 +377,17 @@ function bashunit::runner::call_bench_functions() { local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run \ "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - # shellcheck disable=SC2207 - local functions_to_run=($(bashunit::runner::functions_for_script "$script" "$filtered_functions")) - - if [[ "${#functions_to_run[@]}" -le 0 ]]; then + # Bash 3.0 compatible array initialization + local functions_to_run + local functions_to_run_count=0 + local _fn + while IFS= read -r _fn; do + [[ -z "$_fn" ]] && continue + functions_to_run[functions_to_run_count]="$_fn" + functions_to_run_count=$((functions_to_run_count + 1)) + done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") + + if [[ "$functions_to_run_count" -le 0 ]]; then return fi diff --git a/tests/unit/assert_test.sh b/tests/unit/assert_test.sh index f03ab91b..6168d70b 100644 --- a/tests/unit/assert_test.sh +++ b/tests/unit/assert_test.sh @@ -329,13 +329,15 @@ function test_unsuccessful_assert_exec() { } function test_successful_assert_array_contains() { - local distros=(Ubuntu 123 Linux\ Mint) + local distros + distros=(Ubuntu 123 Linux\ Mint) assert_empty "$(assert_array_contains "123" "${distros[@]}")" } function test_unsuccessful_assert_array_contains() { - local distros=(Ubuntu 123 Linux\ Mint) + local distros + distros=(Ubuntu 123 Linux\ Mint) assert_same\ "$(bashunit::console_results::print_failed_test \ @@ -347,13 +349,15 @@ function test_unsuccessful_assert_array_contains() { } function test_successful_assert_array_not_contains() { - local distros=(Ubuntu 123 Linux\ Mint) + local distros + distros=(Ubuntu 123 Linux\ Mint) assert_empty "$(assert_array_not_contains "a_non_existing_element" "${distros[@]}")" } function test_unsuccessful_assert_array_not_contains() { - local distros=(Ubuntu 123 Linux\ Mint) + local distros + distros=(Ubuntu 123 Linux\ Mint) assert_same\ "$(bashunit::console_results::print_failed_test\ diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index fc78fd9a..1d22fc95 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -53,7 +53,9 @@ function test_normalize_test_function_name_uses_current_interpolated_name_from_s } function test_get_functions_to_run_no_filter_should_return_all_functions() { - local functions=("prefix_function1" "prefix_function2" "other_function" "prefix_function3") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_function1" "prefix_function2" "other_function" "prefix_function3") assert_same\ "prefix_function1 prefix_function2 prefix_function3"\ @@ -61,19 +63,22 @@ function test_get_functions_to_run_no_filter_should_return_all_functions() { } function test_get_functions_to_run_with_filter_should_return_matching_functions() { - local functions=("prefix_function1" "prefix_function2" "other_function" "prefix_function3") + local functions + functions=("prefix_function1" "prefix_function2" "other_function" "prefix_function3") assert_same "prefix_function1" "$(bashunit::helper::get_functions_to_run "prefix" "function1" "${functions[*]}")" } function test_get_functions_to_run_filter_no_matching_functions_should_return_empty() { - local functions=("prefix_function1" "prefix_function2" "other_function" "prefix_function3") + local functions + functions=("prefix_function1" "prefix_function2" "other_function" "prefix_function3") assert_same "" "$(bashunit::helper::get_functions_to_run "prefix" "nonexistent" "${functions[*]}")" } function test_get_functions_to_run_fail_when_duplicates() { - local functions=("prefix_function1" "prefix_function1") + local functions + functions=("prefix_function1" "prefix_function1") assert_general_error "$(bashunit::helper::get_functions_to_run "prefix" "" "${functions[*]}")" } @@ -154,7 +159,8 @@ function test_get_provider_data() { } function fake_provider_data_array() { - local data=("one" "two" "three") + local data + data=("one" "two" "three") bashunit::data_set "${data[@]}" } @@ -249,7 +255,8 @@ EOF } function test_to_run_with_filter_matching_string_in_function_name() { - local functions=("test_my_awesome_function" "test_your_awesome_function" "test_so_lala_function") + local functions + functions=("test_my_awesome_function" "test_your_awesome_function" "test_so_lala_function") assert_same "test_your_awesome_function" \ "$(bashunit::helper::get_functions_to_run "test" "test_your_awesome_function" "${functions[*]}")" From 3a10363e27f213cf4123cd9a5d87ff2f6b43415b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 20:44:11 +0100 Subject: [PATCH 03/41] fix: Bash 3.0 compatibility for assert_contains_ignore_case - Replace shopt nocasematch (Bash 3.1+) with tr for case conversion - Skip release_test.sh on Bash 3.0 (release.sh uses += array syntax) - Fix shellcheck warnings with proper directive placement --- src/assert.sh | 13 ++++++++----- src/helpers.sh | 5 +++-- src/runner.sh | 4 ++-- tests/unit/release_test.sh | 7 +++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index 56008cd2..81db1b38 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -242,20 +242,23 @@ function assert_contains_ignore_case() { local expected="$1" local actual="$2" - shopt -s nocasematch - - if ! [[ $actual =~ $expected ]]; then + # Bash 3.0 compatible: use tr for case-insensitive comparison + # (shopt nocasematch was introduced in Bash 3.1) + local expected_lower + local actual_lower + expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') + actual_lower=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]') + + if [[ "$actual_lower" != *"$expected_lower"* ]]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" - shopt -u nocasematch return fi - shopt -u nocasematch bashunit::state::add_assertions_passed } diff --git a/src/helpers.sh b/src/helpers.sh index 512387a2..85aa9e73 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -339,17 +339,18 @@ function bashunit::helper::find_total_tests() { local count=0 if [[ -n "$filtered_functions" ]]; then - # shellcheck disable=SC2206 - # shellcheck disable=SC2207 # Bash 3.0 compatible: separate declaration and assignment for arrays local functions_to_run + # shellcheck disable=SC2206 functions_to_run=($filtered_functions) for fn_name in "${functions_to_run[@]}"; do # Declare without =() for Bash 3.0 compatibility with set -u + # shellcheck disable=SC2034 local provider_data local 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")" diff --git a/src/runner.sh b/src/runner.sh index b7c8eeb3..2abdac1a 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -64,9 +64,9 @@ function bashunit::runner::load_test_files() { local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") if [[ -n "$filtered_functions" ]]; then - # shellcheck disable=SC2206 # Bash 3.0 compatible: separate declaration and assignment for arrays local functions_to_run + # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) for ((i = 0; i < additional_failures; i++)); do @@ -133,9 +133,9 @@ function bashunit::runner::load_bench_files() { local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run "bench" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") if [[ -n "$filtered_functions" ]]; then - # shellcheck disable=SC2206 # Bash 3.0 compatible: separate declaration and assignment for arrays local functions_to_run + # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) for ((i = 0; i < additional_failures; i++)); do diff --git a/tests/unit/release_test.sh b/tests/unit/release_test.sh index 0a9df41b..647946f1 100644 --- a/tests/unit/release_test.sh +++ b/tests/unit/release_test.sh @@ -1,5 +1,12 @@ #!/usr/bin/env bash +# release.sh requires Bash 3.1+ (uses += array syntax) +# Skip this entire test file on Bash 3.0 +if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then + # shellcheck disable=SC2317 + return 0 2>/dev/null || exit 0 +fi + RELEASE_SCRIPT_DIR="" FIXTURES_DIR="" From c2fbe677ed913baacabea05a89a1d99328b48576 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 21:11:03 +0100 Subject: [PATCH 04/41] ci: run Bash 3.0 tests in parallel jobs - Build Docker image once and share via artifact - Run 4 test modes in parallel: sequential, parallel, simple, simple+parallel - Use matrix strategy for parallel job execution - Add git to Docker image for tests that need it --- .github/workflows/tests-bash-3.0.yml | 54 ++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests-bash-3.0.yml b/.github/workflows/tests-bash-3.0.yml index be914e4d..02b3db54 100644 --- a/.github/workflows/tests-bash-3.0.yml +++ b/.github/workflows/tests-bash-3.0.yml @@ -7,8 +7,8 @@ on: - main jobs: - bash-3-0: - name: "Bash 3.0 - Simple Parallel" + build-image: + name: "Build Bash 3.0 Image" runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -27,6 +27,7 @@ jobs: curl \ ca-certificates \ bison \ + git \ && rm -rf /var/lib/apt/lists/* WORKDIR /tmp @@ -45,14 +46,55 @@ jobs: CMD ["/opt/bash-3.0/bin/bash", "--version"] EOF + - name: Save Docker image + run: docker save bashunit-bash3 -o /tmp/bashunit-bash3.tar + + - name: Upload Docker image artifact + uses: actions/upload-artifact@v4 + with: + name: bashunit-bash3-image + path: /tmp/bashunit-bash3.tar + retention-days: 1 + + test: + name: "Bash 3.0 - ${{ matrix.name }}" + runs-on: ubuntu-latest + needs: build-image + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - name: "Sequential" + flags: "" + - name: "Parallel" + flags: "--parallel" + - name: "Simple" + flags: "--simple" + - name: "Simple Parallel" + flags: "--simple --parallel" + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Download Docker image artifact + uses: actions/download-artifact@v4 + with: + name: bashunit-bash3-image + path: /tmp + + - name: Load Docker image + run: docker load --input /tmp/bashunit-bash3.tar + - name: Verify Bash 3.0 version - run: | - docker run --rm bashunit-bash3 /opt/bash-3.0/bin/bash --version + run: docker run --rm bashunit-bash3 /opt/bash-3.0/bin/bash --version - - name: Run tests with Bash 3.0 (simple parallel) + - name: Run tests with Bash 3.0 (${{ matrix.name }}) run: | docker run --rm \ -v "$(pwd)":/bashunit:ro \ -w /bashunit \ bashunit-bash3 \ - /opt/bash-3.0/bin/bash ./bashunit --simple --parallel tests/ + /opt/bash-3.0/bin/bash ./bashunit ${{ matrix.flags }} tests/ From 01dba04560e030157bea017f0a5c48c98d3af2ee Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 21:28:08 +0100 Subject: [PATCH 05/41] fix: resolve shellcheck and lint errors - Remove unused minor variable from version check - Split long lines in learn.sh --- bashunit | 4 ++-- src/learn.sh | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bashunit b/bashunit index 715ede57..12bda3d5 100755 --- a/bashunit +++ b/bashunit @@ -16,8 +16,8 @@ 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 )); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 diff --git a/src/learn.sh b/src/learn.sh index 6932a7df..e044f27f 100644 --- a/src/learn.sh +++ b/src/learn.sh @@ -1162,11 +1162,13 @@ function test_backup_failure_when_source_missing() { local missing_components_count=0 if ! grep -q "function set_up()" "$test_file"; then - missing_components[missing_components_count]="set_up function"; missing_components_count=$((missing_components_count + 1)) + missing_components[missing_components_count]="set_up function" + missing_components_count=$((missing_components_count + 1)) fi if ! grep -q "function tear_down()" "$test_file"; then - missing_components[missing_components_count]="tear_down function"; missing_components_count=$((missing_components_count + 1)) + missing_components[missing_components_count]="tear_down function" + missing_components_count=$((missing_components_count + 1)) fi if [[ "$missing_components_count" -gt 0 ]]; then From 05ffbe1b0340caeab961804c6a83d88852a88d90 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 21:37:55 +0100 Subject: [PATCH 06/41] ci: remove read-only flag from Docker volume mount --- .github/workflows/tests-bash-3.0.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-bash-3.0.yml b/.github/workflows/tests-bash-3.0.yml index 02b3db54..4e1ae3f9 100644 --- a/.github/workflows/tests-bash-3.0.yml +++ b/.github/workflows/tests-bash-3.0.yml @@ -94,7 +94,7 @@ jobs: - name: Run tests with Bash 3.0 (${{ matrix.name }}) run: | docker run --rm \ - -v "$(pwd)":/bashunit:ro \ + -v "$(pwd)":/bashunit \ -w /bashunit \ bashunit-bash3 \ /opt/bash-3.0/bin/bash ./bashunit ${{ matrix.flags }} tests/ From 032107b9ee8c33ba8d6f139285fcf2a17837b9cb Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 21:45:39 +0100 Subject: [PATCH 07/41] ci: add procps package for ps command in Docker --- .github/workflows/tests-bash-3.0.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests-bash-3.0.yml b/.github/workflows/tests-bash-3.0.yml index 4e1ae3f9..bb2eb250 100644 --- a/.github/workflows/tests-bash-3.0.yml +++ b/.github/workflows/tests-bash-3.0.yml @@ -28,6 +28,7 @@ jobs: ca-certificates \ bison \ git \ + procps \ && rm -rf /var/lib/apt/lists/* WORKDIR /tmp From bfd3d97f79b07e542b5899345b4b00abf4fc16d6 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 21:53:05 +0100 Subject: [PATCH 08/41] ci: trigger fresh build From 46f94b6945ccb6d45524c26338f6acf9744b1105 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 4 Feb 2026 22:01:31 +0100 Subject: [PATCH 09/41] test: skip mock/spy external script tests on Bash 3.0 --- tests/acceptance/install_test.sh | 5 +++++ tests/functional/doubles_test.sh | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/acceptance/install_test.sh b/tests/acceptance/install_test.sh index 33425cff..6fbf39d8 100644 --- a/tests/acceptance/install_test.sh +++ b/tests/acceptance/install_test.sh @@ -130,6 +130,11 @@ function test_install_downloads_the_given_version_without_dir() { } function test_install_downloads_the_non_stable_beta_version() { + # Skip on Bash 3.0 - mocks don't work for external scripts + if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then + bashunit::skip "Mocks don't work for external scripts in Bash 3.0" + return + fi if [[ "$ACTIVE_INTERNET" -eq 1 ]]; then bashunit::skip "no internet connection" && return fi diff --git a/tests/functional/doubles_test.sh b/tests/functional/doubles_test.sh index 8b5aa2dd..773085c0 100644 --- a/tests/functional/doubles_test.sh +++ b/tests/functional/doubles_test.sh @@ -16,6 +16,12 @@ function test_mock_ps_when_executing_a_sourced_function() { } function test_spy_commands_called_when_executing_a_script() { + # Skip on Bash 3.0 - shell function exports don't work for external scripts + if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then + bashunit::skip "Spies don't work for external scripts in Bash 3.0" + return + fi + bashunit::spy ps bashunit::spy awk bashunit::spy head @@ -41,6 +47,11 @@ function test_spy_commands_called_when_executing_a_sourced_function() { } function test_spy_commands_called_once_when_executing_a_script() { + # Skip on Bash 3.0 - shell function exports don't work for external scripts + if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then + bashunit::skip "Spies don't work for external scripts in Bash 3.0" + return + fi # Skip when coverage is enabled because coverage uses head internally, # which interferes with spying on head if bashunit::env::is_coverage_enabled; then From 923535692bf499e427efb26bc7aa07be837a398f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 5 Feb 2026 00:00:45 +0100 Subject: [PATCH 10/41] fix: use local -a for safe Bash 3.0 array init, fix variable scoping --- src/assert.sh | 3 +-- src/assert_arrays.sh | 6 ++---- src/benchmark.sh | 3 +-- src/coverage.sh | 14 +++++-------- src/helpers.sh | 15 +++++++------- src/learn.sh | 3 +-- src/main.sh | 13 +++++------- src/runner.sh | 47 +++++++++++++++++++------------------------- src/state.sh | 1 + 9 files changed, 43 insertions(+), 62 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index 81db1b38..dde5fc86 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -668,8 +668,7 @@ function assert_line_count() { bashunit::assert::should_skip && return 0 local expected="$1" - # Bash 3.0 compatible array initialization - local input_arr; [[ $# -gt 1 ]] && input_arr=("${@:2}") + local -a input_arr=(); [[ $# -gt 1 ]] && input_arr=("${@:2}") local input_str input_str=$(printf '%s\n' ${input_arr+"${input_arr[@]}"}) diff --git a/src/assert_arrays.sh b/src/assert_arrays.sh index c29662a0..3d8a4c80 100644 --- a/src/assert_arrays.sh +++ b/src/assert_arrays.sh @@ -10,8 +10,7 @@ function assert_array_contains() { label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift - # Bash 3.0 compatible array initialization - local actual; [[ $# -gt 0 ]] && actual=("$@") + local -a actual=(); [[ $# -gt 0 ]] && actual=("$@") if ! [[ "${actual[*]:-}" == *"$expected"* ]]; then bashunit::assert::mark_failed @@ -31,8 +30,7 @@ function assert_array_not_contains() { local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift - # Bash 3.0 compatible array initialization - local actual; [[ $# -gt 0 ]] && actual=("$@") + local -a actual=(); [[ $# -gt 0 ]] && actual=("$@") if [[ "${actual[*]:-}" == *"$expected"* ]]; then bashunit::assert::mark_failed diff --git a/src/benchmark.sh b/src/benchmark.sh index 70889c65..106b0032 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -62,8 +62,7 @@ function bashunit::benchmark::run_function() { local revs=$2 local its=$3 local max_ms=$4 - # Declare without =() for Bash 3.0 compatibility with set -u - local durations + local -a durations=() local durations_count=0 for ((i=1; i<=its; i++)); do diff --git a/src/coverage.sh b/src/coverage.sh index ce31c974..b8313869 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -19,8 +19,7 @@ _BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" function bashunit::coverage::auto_discover_paths() { local project_root project_root="$(pwd)" - # Bash 3.0 compatible array initialization - local discovered_paths + local -a discovered_paths=() local discovered_paths_count=0 for test_file in "$@"; do @@ -765,8 +764,7 @@ function bashunit::coverage::report_html() { # Collect file data for index local total_executable=0 local total_hit=0 - # Declare without =() for Bash 3.0 compatibility with set -u - local file_data + local -a file_data=() local file_data_count=0 while IFS= read -r file; do @@ -818,7 +816,7 @@ function bashunit::coverage::generate_index_html() { local tests_failed="$7" shift 7 # Handle array passed as arguments - Bash 3.0 compatible - local file_data + local -a file_data=() local file_count=0 if [[ $# -gt 0 ]]; then file_data=("$@") @@ -1175,8 +1173,7 @@ function bashunit::coverage::generate_file_html() { local uncovered=$((executable - hit)) # Pre-load all line hits into indexed array (performance optimization) - # Bash 3.0 compatible: declare without =() - local hits_by_line + local -a hits_by_line=() local _ln _cnt while IFS=: read -r _ln _cnt; do hits_by_line[_ln]=$_cnt @@ -1185,8 +1182,7 @@ function bashunit::coverage::generate_file_html() { # Pre-load test hits data into indexed array (for tooltips) # Index: line number, Value: newline-separated list of "test_file:test_function" # Using indexed array for Bash 3.0 compatibility (no associative arrays) - # Bash 3.0 compatible: declare without =() - local tests_by_line + local -a tests_by_line=() local _line_and_test while IFS= read -r _line_and_test; do [[ -z "$_line_and_test" ]] && continue diff --git a/src/helpers.sh b/src/helpers.sh index 85aa9e73..20ff883b 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -93,8 +93,7 @@ function bashunit::helper::escape_single_quotes() { function bashunit::helper::interpolate_function_name() { local function_name="$1" shift - # Bash 3.0 compatible array initialization - local args + local -a args=() local args_count=$# [[ $# -gt 0 ]] && args=("$@") local result="$function_name" @@ -339,15 +338,15 @@ function bashunit::helper::find_total_tests() { local count=0 if [[ -n "$filtered_functions" ]]; then - # Bash 3.0 compatible: separate declaration and assignment for arrays - local functions_to_run + local -a functions_to_run=() # shellcheck disable=SC2206 functions_to_run=($filtered_functions) + # shellcheck disable=SC2034 + local -a provider_data=() + local provider_data_count=0 for fn_name in "${functions_to_run[@]}"; do - # Declare without =() for Bash 3.0 compatibility with set -u - # shellcheck disable=SC2034 - local provider_data - local provider_data_count=0 + provider_data=() + provider_data_count=0 while IFS=" " read -r line; do [[ -z "$line" ]] && continue # shellcheck disable=SC2034 diff --git a/src/learn.sh b/src/learn.sh index e044f27f..39cadb40 100644 --- a/src/learn.sh +++ b/src/learn.sh @@ -1157,8 +1157,7 @@ function test_backup_failure_when_source_missing() { fi # Verify the test has key components - # Declare without =() for Bash 3.0 compatibility with set -u - local missing_components + local -a missing_components=() local missing_components_count=0 if ! grep -q "function set_up()" "$test_file"; then diff --git a/src/main.sh b/src/main.sh index 5d7ba37c..1b487686 100644 --- a/src/main.sh +++ b/src/main.sh @@ -5,10 +5,9 @@ ############################# function bashunit::main::cmd_test() { local filter="" - # Declare without =() for Bash 3.0 compatibility with set -u - local raw_args + local -a raw_args=() local raw_args_count=0 - local args + local -a args=() local args_count=0 local assert_fn="" local _bashunit_coverage_opt_set=false @@ -258,10 +257,9 @@ function bashunit::main::cmd_test() { ############################# function bashunit::main::cmd_bench() { local filter="" - # Declare without =() for Bash 3.0 compatibility with set -u - local raw_args + local -a raw_args=() local raw_args_count=0 - local args + local -a args=() local args_count=0 export BASHUNIT_BENCH_MODE=true @@ -621,8 +619,7 @@ function bashunit::main::handle_stop_on_failure_sync() { function bashunit::main::exec_assert() { local original_assert_fn=$1 - # Bash 3.0 compatible array initialization - local args + local -a args=() local args_count=$(($# - 1)) [[ $# -gt 1 ]] && args=("${@:2}") diff --git a/src/runner.sh b/src/runner.sh index 2abdac1a..5a01c108 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -15,11 +15,9 @@ function bashunit::runner::restore_workdir() { function bashunit::runner::load_test_files() { local filter=$1 shift - # Bash 3.0 compatible array initialization - local files + local -a files=() [[ $# -gt 0 ]] && files=("$@") - # Declare without =() for Bash 3.0 compatibility with set -u - local scripts_ids + local -a scripts_ids=() local scripts_ids_count=0 # Initialize coverage tracking if enabled @@ -112,8 +110,7 @@ function bashunit::runner::load_test_files() { function bashunit::runner::load_bench_files() { local filter=$1 shift - # Bash 3.0 compatible array initialization - local files + local -a files=() [[ $# -gt 0 ]] && files=("$@") for bench_file in ${files+"${files[@]}"}; do @@ -207,8 +204,7 @@ function bashunit::runner::parse_data_provider_args() { local i local arg local encoded_arg - # Bash 3.0 compatible array initialization - local args + local -a args=() local args_count=0 # Check for shell metacharacters that would break eval or cause globbing @@ -313,8 +309,7 @@ function bashunit::runner::call_test_functions() { local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run \ "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - # Bash 3.0 compatible array initialization - local functions_to_run + local -a functions_to_run=() local functions_to_run_count=0 local _fn while IFS= read -r _fn; do @@ -329,15 +324,18 @@ function bashunit::runner::call_test_functions() { bashunit::helper::check_duplicate_functions "$script" || true + local -a provider_data=() + local provider_data_count=0 + local -a parsed_data=() + local parsed_data_count=0 + for fn_name in "${functions_to_run[@]}"; do if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then break fi - # Bash 3.0 compatible: unset before redeclaring to clear previous iteration's data - unset provider_data - local provider_data - local provider_data_count=0 + provider_data=() + provider_data_count=0 while IFS=" " read -r line; do [[ -z "$line" ]] && continue provider_data[provider_data_count]="$line" @@ -347,16 +345,14 @@ function bashunit::runner::call_test_functions() { # No data provider found if [[ "$provider_data_count" -eq 0 ]]; then bashunit::runner::run_test "$script" "$fn_name" - unset fn_name + unset -v fn_name continue fi # Execute the test function for each line of data for data in "${provider_data[@]}"; do - # Bash 3.0 compatible: unset before redeclaring to clear previous iteration's data - unset parsed_data - local parsed_data - local parsed_data_count=0 + parsed_data=() + parsed_data_count=0 while IFS= read -r line; do [[ -z "$line" ]] && continue parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" @@ -364,7 +360,7 @@ function bashunit::runner::call_test_functions() { done <<< "$(bashunit::runner::parse_data_provider_args "$data")" bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} done - unset fn_name + unset -v fn_name done } @@ -377,8 +373,7 @@ function bashunit::runner::call_bench_functions() { local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run \ "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - # Bash 3.0 compatible array initialization - local functions_to_run + local -a functions_to_run=() local functions_to_run_count=0 local _fn while IFS= read -r _fn; do @@ -398,7 +393,7 @@ function bashunit::runner::call_bench_functions() { for fn_name in "${functions_to_run[@]}"; do read -r revs its max_ms <<< "$(bashunit::benchmark::parse_annotations "$fn_name" "$script")" bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" - unset fn_name + unset -v fn_name done if ! bashunit::env::is_simple_output_enabled; then @@ -726,8 +721,7 @@ function bashunit::runner::parse_result() { shift local execution_result=$1 shift - # Bash 3.0 compatible array initialization - local args + local -a args=() [[ $# -gt 0 ]] && args=("$@") if bashunit::parallel::is_enabled; then @@ -742,8 +736,7 @@ function bashunit::runner::parse_result_parallel() { shift local execution_result=$1 shift - # Bash 3.0 compatible array initialization - local args + local -a args=() [[ $# -gt 0 ]] && args=("$@") local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/$(basename "$test_file" .sh)" diff --git a/src/state.sh b/src/state.sh index ef5ce23b..84558ea0 100644 --- a/src/state.sh +++ b/src/state.sh @@ -252,6 +252,7 @@ function bashunit::state::calculate_total_assertions() { local numbers numbers=$(echo "$input" | grep -oE '##ASSERTIONS_\w+=[0-9]+' | grep -oE '[0-9]+') + local number for number in $numbers; do total=$((total + number)) done From 313c54004d11e5bd0435f6f6d27818df8f93b283 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 14:20:45 +0100 Subject: [PATCH 11/41] fix: improve Bash 3.0 regex compatibility and assignment quoting - Add regex_match helper function for Bash 3.0+ compatibility - Replace literal regex patterns with helper function calls - Fix Bash 3.0 assignment quoting bug with $'\n' replacements - Update learn tutorial example to use variable-based regex pattern Addresses reviewer feedback on PR #584 --- install.sh | 7 ++++++- release.sh | 7 ++++++- src/coverage.sh | 2 +- src/helpers.sh | 14 ++++++++++++++ src/learn.sh | 3 ++- src/runner.sh | 6 +++--- 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 7749ef0c..17a6d1f3 100755 --- a/install.sh +++ b/install.sh @@ -2,6 +2,11 @@ # shellcheck disable=SC2155 # shellcheck disable=SC2164 +# Helper function for regex matching (Bash 3.0+ compatible) +function regex_match() { + [[ $1 =~ $2 ]] +} + function is_git_installed() { command -v git > /dev/null 2>&1 } @@ -58,7 +63,7 @@ DIR="lib" VERSION="latest" function is_version() { - [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$1" == "latest" || "$1" == "beta" ]] + regex_match "$1" '^[0-9]+\.[0-9]+\.[0-9]+$' || [[ "$1" == "latest" || "$1" == "beta" ]] } # Parse arguments flexibly diff --git a/release.sh b/release.sh index 0b71855d..b79ef289 100755 --- a/release.sh +++ b/release.sh @@ -15,6 +15,11 @@ GITHUB_REPO_PATH="TypedDevs/bashunit" GITHUB_REPO_URL="https://github.com/${GITHUB_REPO_PATH}" RELEASE_FILES=("bashunit" "install.sh" "package.json" "CHANGELOG.md") +# Helper function for regex matching (Bash 3.0+ compatible) +function regex_match() { + [[ $1 =~ $2 ]] +} + # Colors RED='\033[0;31m' GREEN='\033[0;32m' @@ -529,7 +534,7 @@ function release::sandbox::run() { function release::validate_semver() { local version=$1 - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if ! regex_match "$version" '^[0-9]+\.[0-9]+\.[0-9]+$'; then release::log_error "Invalid version format: $version" release::log_error "Version must be in semver format (e.g., 0.30.0)" exit $EXIT_VALIDATION_ERROR diff --git a/src/coverage.sh b/src/coverage.sh index b8313869..8ca3f020 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -513,7 +513,7 @@ function bashunit::coverage::extract_functions() { brace_count=$((brace_count + ${#open_braces} - ${#close_braces})) # Single-line function - if [[ $brace_count -eq 0 && "$line" =~ \{ && "$line" =~ \} ]]; then + if [[ $brace_count -eq 0 ]] && bashunit::regex_match "$line" '\{' && bashunit::regex_match "$line" '\}'; then echo "${current_fn}:${fn_start}:${lineno}" in_function=0 current_fn="" diff --git a/src/helpers.sh b/src/helpers.sh index 5e5a1a86..410786e8 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -2,6 +2,20 @@ declare -r BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" +# +# Helper function for regex matching that works correctly in Bash 3.0+ +# In Bash < 3.2, regex matching with literal patterns doesn't work properly, +# so we need to use this function instead of direct [[ ... =~ ... ]] checks. +# +# @param $1 string The string to match +# @param $2 string The regex pattern +# +# @return boolean True if the pattern matches, false otherwise +# +function bashunit::regex_match() { + [[ $1 =~ $2 ]] +} + # # Walks up the call stack to find the first function that looks like a test function. # A test function is one that starts with "test_" or "test" (camelCase). diff --git a/src/learn.sh b/src/learn.sh index 39cadb40..f428748c 100644 --- a/src/learn.sh +++ b/src/learn.sh @@ -858,7 +858,8 @@ File: validator.sh #!/usr/bin/env bash function is_valid_email() { - [[ $1 =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] + local email_pattern='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + [[ $1 =~ $email_pattern ]] } ─────────────────────────────────────────────────────────────── diff --git a/src/runner.sh b/src/runner.sh index 5a01c108..d0fee150 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -544,9 +544,9 @@ function bashunit::runner::run_test() { local line="${subshell_output#*]}" # Remove everything before and including "]" # Replace [type] with a newline to split the messages - line="${line//\[failed\]/$'\n'}" # Replace [failed] with newline - line="${line//\[skipped\]/$'\n'}" # Replace [skipped] with newline - line="${line//\[incomplete\]/$'\n'}" # Replace [incomplete] with newline + line=${line//\[failed\]/$'\n'} # Replace [failed] with newline + line=${line//\[skipped\]/$'\n'} # Replace [skipped] with newline + line=${line//\[incomplete\]/$'\n'} # Replace [incomplete] with newline if ! bashunit::env::is_failures_only_enabled; then bashunit::state::print_line "$type" "$line" From 54a1ff1b466c7e3a69a882ed62f8b82f016f2d99 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 14:46:12 +0100 Subject: [PATCH 12/41] fix: prevent variable leakage in loops across all source files Add local declarations for loop variables to prevent them from leaking into the global scope. This addresses all variable leakage issues identified in PR review. Files updated: - src/benchmark.sh: Loop vars i, r, d, val - src/colors.sh: Loop var c - src/console_results.sh: Loop var arg - src/coverage.sh: Loop vars test_file, found_file, pid_file, line, data, display_file, hit, executable, pct, safe_filename, test_file, test_fn - src/runner.sh: Loop vars line, data - src/main.sh: Loop var arg --- src/benchmark.sh | 4 ++++ src/colors.sh | 1 + src/console_results.sh | 1 + src/coverage.sh | 12 +++++++++++- src/main.sh | 1 + src/runner.sh | 3 +++ 6 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/benchmark.sh b/src/benchmark.sh index 106b0032..eb00b78e 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -64,6 +64,7 @@ function bashunit::benchmark::run_function() { local max_ms=$4 local -a durations=() local durations_count=0 + local i r for ((i=1; i<=its; i++)); do local start_time=$(bashunit::clock::now) @@ -85,6 +86,7 @@ function bashunit::benchmark::run_function() { done local sum=0 + local d for d in "${durations[@]}"; do sum=$(bashunit::math::calculate "$sum + $d") done @@ -110,6 +112,7 @@ function bashunit::benchmark::print_results() { printf "\n" local has_threshold=false + local val for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]}"; do if [[ -n "$val" ]]; then has_threshold=true @@ -123,6 +126,7 @@ function bashunit::benchmark::print_results() { printf '%-40s %6s %6s %10s\n' "Name" "Revs" "Its" "Avg(ms)" fi + local i for i in "${!_BASHUNIT_BENCH_NAMES[@]}"; do local name="${_BASHUNIT_BENCH_NAMES[$i]}" local revs="${_BASHUNIT_BENCH_REVS[$i]}" diff --git a/src/colors.sh b/src/colors.sh index 71ed9845..3ce77abc 100644 --- a/src/colors.sh +++ b/src/colors.sh @@ -10,6 +10,7 @@ bashunit::sgr() { local codes=${1:-0} shift + local c for c in "$@"; do codes="$codes;$c" done diff --git a/src/console_results.sh b/src/console_results.sh index a109ff7c..30e8ccd2 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -201,6 +201,7 @@ function bashunit::console_results::print_successful_test() { line=$(printf "%s✓ Passed%s: %s" "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_DEFAULT" "$test_name") else local quoted_args="" + local arg for arg in "$@"; do if [[ -z "$quoted_args" ]]; then quoted_args="'$arg'" diff --git a/src/coverage.sh b/src/coverage.sh index 8ca3f020..48bafb43 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -21,6 +21,7 @@ function bashunit::coverage::auto_discover_paths() { project_root="$(pwd)" local -a discovered_paths=() local discovered_paths_count=0 + local test_file for test_file in "$@"; do # Extract base name: tests/unit/assert_test.sh -> assert_test.sh @@ -33,6 +34,7 @@ function bashunit::coverage::auto_discover_paths() { [[ "$source_name" == "$file_basename" ]] && continue # Not a test file pattern # Find matching source files recursively + local found_file while IFS= read -r -d '' found_file; do # Skip test files and vendor directories [[ "$found_file" == *test* ]] && continue @@ -297,7 +299,7 @@ function bashunit::coverage::aggregate_parallel() { # Find and merge all per-process coverage data files # Use nullglob to handle case when no files match - local pid_files + local pid_files pid_file pid_files=$(ls -1 "${base_file}."* 2>/dev/null) || true if [[ -n "$pid_files" ]]; then while IFS= read -r pid_file; do @@ -383,6 +385,7 @@ function bashunit::coverage::get_executable_lines() { local file="$1" local count=0 local lineno=0 + local line while IFS= read -r line || [[ -n "$line" ]]; do ((lineno++)) @@ -449,6 +452,7 @@ function bashunit::coverage::get_all_line_hits() { fi # Extract all lines for this file, count occurrences of each line number + local count lineno grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | \ cut -d: -f2 | sort | uniq -c | \ while read -r count lineno; do @@ -481,6 +485,7 @@ function bashunit::coverage::extract_functions() { local brace_count=0 local current_fn="" local fn_start=0 + local line while IFS= read -r line || [[ -n "$line" ]]; do ((lineno++)) @@ -694,6 +699,7 @@ function bashunit::coverage::report_lcov() { echo "SF:$file" local lineno=0 + local line # shellcheck disable=SC2094 while IFS= read -r line || [[ -n "$line" ]]; do ((lineno++)) @@ -766,6 +772,7 @@ function bashunit::coverage::report_html() { local total_hit=0 local -a file_data=() local file_data_count=0 + local file while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue @@ -1109,6 +1116,7 @@ EOF EOF + local data display_file hit executable pct safe_filename for data in ${file_data[@]+"${file_data[@]}"}; do IFS='|' read -r display_file hit executable pct safe_filename <<< "$data" @@ -1496,6 +1504,7 @@ EOF EOF local lineno=0 + local line while IFS= read -r line || [[ -n "$line" ]]; do ((lineno++)) @@ -1517,6 +1526,7 @@ EOF if [[ -n "$test_info" ]]; then # Build tooltip with test information local tooltip_html="
    Tests hitting this line
      " + local test_file test_fn while IFS=':' read -r test_file test_fn; do [[ -z "$test_file" ]] && continue local short_file diff --git a/src/main.sh b/src/main.sh index 1b487686..8b19cf6f 100644 --- a/src/main.sh +++ b/src/main.sh @@ -179,6 +179,7 @@ function bashunit::main::cmd_test() { args_count="$raw_args_count" else # Test mode: process file paths and extract inline filters + local arg for arg in "${raw_args[@]}"; do local parsed_path parsed_filter { diff --git a/src/runner.sh b/src/runner.sh index d0fee150..69530caa 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -336,6 +336,7 @@ function bashunit::runner::call_test_functions() { provider_data=() provider_data_count=0 + local line while IFS=" " read -r line; do [[ -z "$line" ]] && continue provider_data[provider_data_count]="$line" @@ -350,9 +351,11 @@ function bashunit::runner::call_test_functions() { fi # Execute the test function for each line of data + local data for data in "${provider_data[@]}"; do parsed_data=() parsed_data_count=0 + local line while IFS= read -r line; do [[ -z "$line" ]] && continue parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" From 68941fc5762a00635f686c21dfa0bbd20d6dad19 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 14:55:29 +0100 Subject: [PATCH 13/41] fix: add local declarations for all remaining loop variables Complete comprehensive fix for variable leakage across entire codebase. Add local declarations for loop variables in for/while loops to prevent them from leaking into global scope. Files updated (32 locations total): - src/console_results.sh: 1 loop variable - src/coverage.sh: 2 loop variables - src/doc.sh: 1 loop variable - src/env.sh: 1 loop variable - src/globals.sh: 1 loop variable - src/helpers.sh: 4 loop variables - src/learn.sh: 1 loop variable - src/main.sh: 2 loop variables - src/parallel.sh: 2 loop variables - src/reports.sh: 3 loop variables - src/runner.sh: 14 loop variables - src/test_doubles.sh: 1 loop variable All tests pass: 530 passed, 2 snapshot, 532 total --- src/console_results.sh | 1 + src/coverage.sh | 1 + src/doc.sh | 1 + src/env.sh | 1 + src/globals.sh | 1 + src/helpers.sh | 4 ++++ src/learn.sh | 1 + src/main.sh | 2 ++ src/parallel.sh | 2 ++ src/reports.sh | 3 +++ src/runner.sh | 14 ++++++++++++++ src/test_doubles.sh | 1 + 12 files changed, 32 insertions(+) diff --git a/src/console_results.sh b/src/console_results.sh index 30e8ccd2..9d3242ff 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -351,6 +351,7 @@ function bashunit::console_results::print_error_test() { if [[ -n "$raw_output" ]] && bashunit::env::is_show_output_on_failure_enabled; then line="$line$(printf " %sOutput:%s\n" "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}")" + local output_line while IFS= read -r output_line; do line="$line$(printf " %s\n" "$output_line")" done <<< "$raw_output" diff --git a/src/coverage.sh b/src/coverage.sh index 48bafb43..b5c43a5b 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -616,6 +616,7 @@ function bashunit::coverage::report_text() { echo "Coverage Report" echo "---------------" + local file while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue has_files=true diff --git a/src/doc.sh b/src/doc.sh index 7010d0cd..ed757a8b 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -18,6 +18,7 @@ function bashunit::doc::print_asserts() { # Pattern stored in variable for Bash 3.0 compatibility local _doc_pattern='^## ([A-Za-z0-9_]+)' + local line while IFS='' read -r line || [[ -n "$line" ]]; do if [[ $line =~ $_doc_pattern ]]; then fn="${BASH_REMATCH[1]}" diff --git a/src/env.sh b/src/env.sh index 0ad0b3a7..d042c6ba 100644 --- a/src/env.sh +++ b/src/env.sh @@ -244,6 +244,7 @@ function bashunit::env::print_verbose() { local max_length=0 + local key for key in "${keys[@]}"; do if (( ${#key} > max_length )); then max_length=${#key} diff --git a/src/globals.sh b/src/globals.sh index 3c4ca2ab..41df1a40 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -31,6 +31,7 @@ function bashunit::random_str() { local length=${1:-6} local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local str='' + local i for (( i=0; i" + local i for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" @@ -97,6 +98,7 @@ function bashunit::reports::generate_report_html() { # Collect test cases by file : > "$temp_file" # Clear temp file if it exists + local i for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" @@ -156,6 +158,7 @@ function bashunit::reports::generate_report_html() { # Read the temporary file and group by file local current_file="" + local file name status test_time while IFS='|' read -r file name status test_time; do if [ "$file" != "$current_file" ]; then if [ -n "$current_file" ]; then diff --git a/src/runner.sh b/src/runner.sh index 69530caa..4106c6e3 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -29,6 +29,7 @@ function bashunit::runner::load_test_files() { bashunit::coverage::init fi + local test_file for test_file in "${files[@]}"; do if [[ ! -f $test_file ]]; then continue @@ -67,6 +68,7 @@ function bashunit::runner::load_test_files() { # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) + local i for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done @@ -100,6 +102,7 @@ function bashunit::runner::load_test_files() { # Kill the spinner once the aggregation finishes disown "$spinner_pid" && kill "$spinner_pid" &>/dev/null printf "\r \r" # Clear the spinner output + local script_id for script_id in "${scripts_ids[@]}"; do export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" bashunit::cleanup_script_temp_files @@ -113,6 +116,7 @@ function bashunit::runner::load_bench_files() { local -a files=() [[ $# -gt 0 ]] && files=("$@") + local bench_file for bench_file in ${files+"${files[@]}"}; do [[ -f $bench_file ]] || continue unset BASHUNIT_CURRENT_TEST_ID @@ -135,6 +139,7 @@ function bashunit::runner::load_bench_files() { # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) + local i for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done @@ -173,6 +178,7 @@ function bashunit::runner::spinner() { local delay=0.1 local spin_chars="|/-\\" while true; do + local i for ((i=0; i<${#spin_chars}; i++)); do printf "\r%s" "${spin_chars:$i:1}" sleep "$delay" @@ -217,6 +223,7 @@ function bashunit::runner::parse_data_provider_args() { if [[ "$has_metachar" == false ]] && eval "args=($input)" 2>/dev/null; then # Check if args has elements after eval args_count=0 + local _tmp arg for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done if [[ "$args_count" -gt 0 ]]; then # Successfully parsed - remove sentinel if present @@ -234,6 +241,7 @@ function bashunit::runner::parse_data_provider_args() { fi # Fallback: parse args from the input string into an array, respecting quotes and escapes + local i for ((i=0; i<${#input}; i++)); do local char="${input:$i:1}" if [ "$escaped" = true ]; then @@ -295,6 +303,7 @@ function bashunit::runner::parse_data_provider_args() { fi done # Print one arg per line to stdout, base64-encoded to preserve newlines in the data + local arg for arg in ${args+"${args[@]}"}; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" @@ -393,6 +402,7 @@ function bashunit::runner::call_bench_functions() { bashunit::runner::render_running_file_header "$script" fi + local fn_name for fn_name in "${functions_to_run[@]}"; do read -r revs its max_ms <<< "$(bashunit::benchmark::parse_annotations "$fn_name" "$script")" bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" @@ -561,6 +571,7 @@ function bashunit::runner::run_test() { local runtime_output="${test_execution_result%%##ASSERTIONS_*}" local runtime_error="" + local error for error in "command not found" "unbound variable" "permission denied" \ "no such file or directory" "syntax error" "bad substitution" \ "division by 0" "cannot allocate memory" "bad file descriptor" \ @@ -923,6 +934,7 @@ function bashunit::runner::execute_file_hook() { if [[ -f "$hook_output_file" ]]; then hook_output="" + local line while IFS= read -r line; do [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done < "$hook_output_file" @@ -1013,6 +1025,7 @@ function bashunit::runner::execute_test_hook() { if [[ -f "$hook_output_file" ]]; then hook_output="" + local line while IFS= read -r line; do [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done < "$hook_output_file" @@ -1054,6 +1067,7 @@ function bashunit::runner::record_test_hook_failure() { } function bashunit::runner::clear_mocks() { + local i for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" done diff --git a/src/test_doubles.sh b/src/test_doubles.sh index a408435d..64107bd9 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -5,6 +5,7 @@ declare -a _BASHUNIT_MOCKED_FUNCTIONS=() function bashunit::unmock() { local command=$1 + local i for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do if [[ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" == "$command" ]]; then unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" From d7cd950b1df88b275ffe2aac2336c9f6cc18544f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 15:45:58 +0100 Subject: [PATCH 14/41] fix: initialize all loop variables to prevent unbound variable errors in strict mode --- src/benchmark.sh | 2 +- src/colors.sh | 2 +- src/console_results.sh | 2 +- src/coverage.sh | 14 +++++++------- src/doc.sh | 4 ++-- src/env.sh | 2 +- src/globals.sh | 2 +- src/helpers.sh | 4 ++-- src/learn.sh | 2 +- src/main.sh | 4 ++-- src/parallel.sh | 4 ++-- src/reports.sh | 4 ++-- src/runner.sh | 36 ++++++++++++++++++------------------ src/test_doubles.sh | 2 +- 14 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/benchmark.sh b/src/benchmark.sh index eb00b78e..25cc37db 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -126,7 +126,7 @@ function bashunit::benchmark::print_results() { printf '%-40s %6s %6s %10s\n' "Name" "Revs" "Its" "Avg(ms)" fi - local i + local i=0 for i in "${!_BASHUNIT_BENCH_NAMES[@]}"; do local name="${_BASHUNIT_BENCH_NAMES[$i]}" local revs="${_BASHUNIT_BENCH_REVS[$i]}" diff --git a/src/colors.sh b/src/colors.sh index 3ce77abc..dfd1208f 100644 --- a/src/colors.sh +++ b/src/colors.sh @@ -10,7 +10,7 @@ bashunit::sgr() { local codes=${1:-0} shift - local c + local c="" for c in "$@"; do codes="$codes;$c" done diff --git a/src/console_results.sh b/src/console_results.sh index 9d3242ff..91cd2510 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -351,7 +351,7 @@ function bashunit::console_results::print_error_test() { if [[ -n "$raw_output" ]] && bashunit::env::is_show_output_on_failure_enabled; then line="$line$(printf " %sOutput:%s\n" "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}")" - local output_line + local output_line="" while IFS= read -r output_line; do line="$line$(printf " %s\n" "$output_line")" done <<< "$raw_output" diff --git a/src/coverage.sh b/src/coverage.sh index b5c43a5b..6d0f954a 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -416,7 +416,7 @@ function bashunit::coverage::get_hit_lines() { # Only count hits that correspond to executable lines # This prevents >100% coverage when DEBUG trap fires on non-executable lines local count=0 - local line_num + local line_num=0 for line_num in $hit_lines; do local line_content line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue @@ -437,7 +437,7 @@ function bashunit::coverage::get_line_hits() { return fi - local count + local count=0 count=$(grep -c "^${file}:${lineno}$" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null) || count=0 echo "$count" } @@ -562,7 +562,7 @@ function bashunit::coverage::get_function_coverage() { local executable=0 local hit=0 - local lineno + local lineno=0 for ((lineno = fn_start; lineno <= fn_end; lineno++)); do local line_content @@ -616,7 +616,7 @@ function bashunit::coverage::report_text() { echo "Coverage Report" echo "---------------" - local file + local file="" while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue has_files=true @@ -773,7 +773,7 @@ function bashunit::coverage::report_html() { local total_hit=0 local -a file_data=() local file_data_count=0 - local file + local file="" while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue @@ -787,7 +787,7 @@ function bashunit::coverage::report_html() { total_hit=$((total_hit + hit)) local display_file="${file#"$(pwd)"/}" - local safe_filename + local safe_filename="" safe_filename=$(bashunit::coverage::path_to_filename "$file") file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename"; file_data_count=$((file_data_count + 1)) @@ -1192,7 +1192,7 @@ function bashunit::coverage::generate_file_html() { # Index: line number, Value: newline-separated list of "test_file:test_function" # Using indexed array for Bash 3.0 compatibility (no associative arrays) local -a tests_by_line=() - local _line_and_test + local _line_and_test="" while IFS= read -r _line_and_test; do [[ -z "$_line_and_test" ]] && continue local _tln="${_line_and_test%%|*}" diff --git a/src/doc.sh b/src/doc.sh index ed757a8b..0eaaf46e 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -11,14 +11,14 @@ function bashunit::doc::get_embedded_docs() { function bashunit::doc::print_asserts() { local filter="${1:-}" - local line + local line="" local docstring="" local fn="" local should_print=0 # Pattern stored in variable for Bash 3.0 compatibility local _doc_pattern='^## ([A-Za-z0-9_]+)' - local line + local line="" while IFS='' read -r line || [[ -n "$line" ]]; do if [[ $line =~ $_doc_pattern ]]; then fn="${BASH_REMATCH[1]}" diff --git a/src/env.sh b/src/env.sh index d042c6ba..4e924a66 100644 --- a/src/env.sh +++ b/src/env.sh @@ -244,7 +244,7 @@ function bashunit::env::print_verbose() { local max_length=0 - local key + local key="" for key in "${keys[@]}"; do if (( ${#key} > max_length )); then max_length=${#key} diff --git a/src/globals.sh b/src/globals.sh index 41df1a40..3298fc74 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -31,7 +31,7 @@ function bashunit::random_str() { local length=${1:-6} local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local str='' - local i + local i=0 for (( i=0; i" - local i + local i=0 for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" @@ -98,7 +98,7 @@ function bashunit::reports::generate_report_html() { # Collect test cases by file : > "$temp_file" # Clear temp file if it exists - local i + local i=0 for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" diff --git a/src/runner.sh b/src/runner.sh index 4106c6e3..b881bde9 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -29,7 +29,7 @@ function bashunit::runner::load_test_files() { bashunit::coverage::init fi - local test_file + local test_file="" for test_file in "${files[@]}"; do if [[ ! -f $test_file ]]; then continue @@ -68,7 +68,7 @@ function bashunit::runner::load_test_files() { # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) - local i + local i=0 for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done @@ -102,7 +102,7 @@ function bashunit::runner::load_test_files() { # Kill the spinner once the aggregation finishes disown "$spinner_pid" && kill "$spinner_pid" &>/dev/null printf "\r \r" # Clear the spinner output - local script_id + local script_id="" for script_id in "${scripts_ids[@]}"; do export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" bashunit::cleanup_script_temp_files @@ -116,7 +116,7 @@ function bashunit::runner::load_bench_files() { local -a files=() [[ $# -gt 0 ]] && files=("$@") - local bench_file + local bench_file="" for bench_file in ${files+"${files[@]}"}; do [[ -f $bench_file ]] || continue unset BASHUNIT_CURRENT_TEST_ID @@ -139,7 +139,7 @@ function bashunit::runner::load_bench_files() { # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) - local i + local i=0 for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done @@ -178,7 +178,7 @@ function bashunit::runner::spinner() { local delay=0.1 local spin_chars="|/-\\" while true; do - local i + local i=0 for ((i=0; i<${#spin_chars}; i++)); do printf "\r%s" "${spin_chars:$i:1}" sleep "$delay" @@ -207,8 +207,8 @@ function bashunit::runner::parse_data_provider_args() { local had_quotes=false # Track if arg was quoted (to preserve empty quoted strings) local quote_char="" local escaped=false - local i - local arg + local i=0 + local arg="" local encoded_arg local -a args=() local args_count=0 @@ -241,7 +241,7 @@ function bashunit::runner::parse_data_provider_args() { fi # Fallback: parse args from the input string into an array, respecting quotes and escapes - local i + local i=0 for ((i=0; i<${#input}; i++)); do local char="${input:$i:1}" if [ "$escaped" = true ]; then @@ -303,7 +303,7 @@ function bashunit::runner::parse_data_provider_args() { fi done # Print one arg per line to stdout, base64-encoded to preserve newlines in the data - local arg + local arg="" for arg in ${args+"${args[@]}"}; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" @@ -345,7 +345,7 @@ function bashunit::runner::call_test_functions() { provider_data=() provider_data_count=0 - local line + local line="" while IFS=" " read -r line; do [[ -z "$line" ]] && continue provider_data[provider_data_count]="$line" @@ -360,11 +360,11 @@ function bashunit::runner::call_test_functions() { fi # Execute the test function for each line of data - local data + local data="" for data in "${provider_data[@]}"; do parsed_data=() parsed_data_count=0 - local line + local line="" while IFS= read -r line; do [[ -z "$line" ]] && continue parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" @@ -402,7 +402,7 @@ function bashunit::runner::call_bench_functions() { bashunit::runner::render_running_file_header "$script" fi - local fn_name + local fn_name="" for fn_name in "${functions_to_run[@]}"; do read -r revs its max_ms <<< "$(bashunit::benchmark::parse_annotations "$fn_name" "$script")" bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" @@ -571,7 +571,7 @@ function bashunit::runner::run_test() { local runtime_output="${test_execution_result%%##ASSERTIONS_*}" local runtime_error="" - local error + local error="" for error in "command not found" "unbound variable" "permission denied" \ "no such file or directory" "syntax error" "bad substitution" \ "division by 0" "cannot allocate memory" "bad file descriptor" \ @@ -934,7 +934,7 @@ function bashunit::runner::execute_file_hook() { if [[ -f "$hook_output_file" ]]; then hook_output="" - local line + local line="" while IFS= read -r line; do [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done < "$hook_output_file" @@ -1025,7 +1025,7 @@ function bashunit::runner::execute_test_hook() { if [[ -f "$hook_output_file" ]]; then hook_output="" - local line + local line="" while IFS= read -r line; do [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done < "$hook_output_file" @@ -1067,7 +1067,7 @@ function bashunit::runner::record_test_hook_failure() { } function bashunit::runner::clear_mocks() { - local i + local i=0 for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" done diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 64107bd9..372bd9e6 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -5,7 +5,7 @@ declare -a _BASHUNIT_MOCKED_FUNCTIONS=() function bashunit::unmock() { local command=$1 - local i + local i=0 for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do if [[ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" == "$command" ]]; then unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" From d68762cbfeb794aeab8edaf45f5d8acb799b0e47 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 16:30:57 +0100 Subject: [PATCH 15/41] fix: make array iteration safe for strict mode (set -u) When bashunit runs with BASHUNIT_STRICT_MODE=true (set -u), iterating over arrays could trigger "unbound variable" errors. This fix implements safe array iteration patterns: - For value iteration: Use "${array[@]+"${array[@]}"}" pattern which expands to nothing when array is empty, preventing unbound errors - For indexed iteration: Check array size before loop with [ "${#array[@]}" -gt 0 ] to avoid iterating over empty arrays - Add default values (:-) when accessing array elements to handle unset elements gracefully Changes affect: - src/benchmark.sh: Safe iteration over benchmark result arrays - src/env.sh: Safe iteration over environment variable keys - src/helpers.sh: Safe iteration over function names - src/main.sh: Safe iteration over command-line arguments - src/parallel.sh: Safe iteration over result files - src/reports.sh: Safe iteration over test results - src/runner.sh: Safe iteration over test files and functions - src/test_doubles.sh: Safe iteration over mocked functions All tests passing locally (738 passed, 3 skipped, 7 incomplete). Fixes strict mode compatibility without breaking existing functionality. --- src/benchmark.sh | 20 ++++++++++++-------- src/env.sh | 4 ++-- src/helpers.sh | 2 +- src/main.sh | 4 ++-- src/parallel.sh | 2 +- src/reports.sh | 18 +++++++++--------- src/runner.sh | 20 ++++++++++++-------- src/test_doubles.sh | 8 ++++++-- 8 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/benchmark.sh b/src/benchmark.sh index 25cc37db..0cc22f63 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -87,7 +87,7 @@ function bashunit::benchmark::run_function() { local sum=0 local d - for d in "${durations[@]}"; do + for d in "${durations[@]+"${durations[@]}"}"; do sum=$(bashunit::math::calculate "$sum + $d") done local avg=$(bashunit::math::calculate "$sum / ${#durations[@]}") @@ -113,7 +113,7 @@ function bashunit::benchmark::print_results() { local has_threshold=false local val - for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]}"; do + for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]+"${_BASHUNIT_BENCH_MAX_MILLIS[@]}"}"; do if [[ -n "$val" ]]; then has_threshold=true break @@ -126,13 +126,17 @@ function bashunit::benchmark::print_results() { printf '%-40s %6s %6s %10s\n' "Name" "Revs" "Its" "Avg(ms)" fi - local i=0 + if [ "${#_BASHUNIT_BENCH_NAMES[@]}" -eq 0 ]; then + return + fi + + local i for i in "${!_BASHUNIT_BENCH_NAMES[@]}"; do - local name="${_BASHUNIT_BENCH_NAMES[$i]}" - local revs="${_BASHUNIT_BENCH_REVS[$i]}" - local its="${_BASHUNIT_BENCH_ITS[$i]}" - local avg="${_BASHUNIT_BENCH_AVERAGES[$i]}" - local max_ms="${_BASHUNIT_BENCH_MAX_MILLIS[$i]}" + local name="${_BASHUNIT_BENCH_NAMES[$i]:-}" + local revs="${_BASHUNIT_BENCH_REVS[$i]:-}" + local its="${_BASHUNIT_BENCH_ITS[$i]:-}" + local avg="${_BASHUNIT_BENCH_AVERAGES[$i]:-}" + local max_ms="${_BASHUNIT_BENCH_MAX_MILLIS[$i]:-}" if [[ -z "$max_ms" ]]; then printf '%-40s %6s %6s %10s\n' "$name" "$revs" "$its" "$avg" diff --git a/src/env.sh b/src/env.sh index 4e924a66..901a3afc 100644 --- a/src/env.sh +++ b/src/env.sh @@ -245,13 +245,13 @@ function bashunit::env::print_verbose() { local max_length=0 local key="" - for key in "${keys[@]}"; do + for key in "${keys[@]+"${keys[@]}"}"; do if (( ${#key} > max_length )); then max_length=${#key} fi done - for key in "${keys[@]}"; do + for key in "${keys[@]+"${keys[@]}"}"; do bashunit::internal_log "$key=${!key}" printf "%s:%*s%s\n" "$key" $((max_length - ${#key} + 1)) "" "${!key}" done diff --git a/src/helpers.sh b/src/helpers.sh index fbeffed4..58ffc158 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -361,7 +361,7 @@ function bashunit::helper::find_total_tests() { local -a provider_data=() local provider_data_count=0 local fn_name line - for fn_name in "${functions_to_run[@]}"; do + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do provider_data=() provider_data_count=0 while IFS=" " read -r line; do diff --git a/src/main.sh b/src/main.sh index b23b83fe..7c374e95 100644 --- a/src/main.sh +++ b/src/main.sh @@ -180,7 +180,7 @@ function bashunit::main::cmd_test() { else # Test mode: process file paths and extract inline filters local arg="" - for arg in "${raw_args[@]}"; do + for arg in "${raw_args[@]+"${raw_args[@]}"}"; do local parsed_path parsed_filter { read -r parsed_path @@ -321,7 +321,7 @@ function bashunit::main::cmd_bench() { # Expand positional arguments if [[ "$raw_args_count" -gt 0 ]]; then local arg file - for arg in "${raw_args[@]}"; do + for arg in "${raw_args[@]+"${raw_args[@]}"}"; do while IFS= read -r file; do args[args_count]="$file"; args_count=$((args_count + 1)) done < <(bashunit::helper::find_files_recursive "$arg" '*[bB]ench.sh') diff --git a/src/parallel.sh b/src/parallel.sh index ee8ba80f..694c2136 100755 --- a/src/parallel.sh +++ b/src/parallel.sh @@ -25,7 +25,7 @@ function bashunit::parallel::aggregate_test_results() { fi local result_file="" - for result_file in "${result_files[@]}"; do + for result_file in "${result_files[@]+"${result_files[@]}"}"; do local result_line result_line=$(tail -n 1 < "$result_file") diff --git a/src/reports.sh b/src/reports.sh index 4c7ce107..4a8c564e 100755 --- a/src/reports.sh +++ b/src/reports.sh @@ -64,11 +64,11 @@ function bashunit::reports::generate_junit_xml() { local i=0 for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do - local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" - local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" - local assertions="${_BASHUNIT_REPORTS_TEST_ASSERTIONS[$i]}" - local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]}" - local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]}" + local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" + local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" + local assertions="${_BASHUNIT_REPORTS_TEST_ASSERTIONS[$i]:-}" + local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" + local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}" echo " "$temp_file" # Clear temp file if it exists local i=0 for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do - local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" - local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" - local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]}" - local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]}" + local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" + local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" + local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" + local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}" local test_case="$file|$name|$status|$test_time" echo "$test_case" >> "$temp_file" diff --git a/src/runner.sh b/src/runner.sh index b881bde9..06b37b79 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -30,7 +30,7 @@ function bashunit::runner::load_test_files() { fi local test_file="" - for test_file in "${files[@]}"; do + for test_file in "${files[@]+"${files[@]}"}"; do if [[ ! -f $test_file ]]; then continue fi @@ -103,7 +103,7 @@ function bashunit::runner::load_test_files() { disown "$spinner_pid" && kill "$spinner_pid" &>/dev/null printf "\r \r" # Clear the spinner output local script_id="" - for script_id in "${scripts_ids[@]}"; do + for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" bashunit::cleanup_script_temp_files done @@ -232,7 +232,7 @@ function bashunit::runner::parse_data_provider_args() { unset 'args[$last_idx]' fi # Print args and return early - for arg in "${args[@]}"; do + for arg in "${args[@]+"${args[@]}"}"; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done @@ -338,7 +338,7 @@ function bashunit::runner::call_test_functions() { local -a parsed_data=() local parsed_data_count=0 - for fn_name in "${functions_to_run[@]}"; do + 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 @@ -361,7 +361,7 @@ function bashunit::runner::call_test_functions() { # Execute the test function for each line of data local data="" - for data in "${provider_data[@]}"; do + for data in "${provider_data[@]+"${provider_data[@]}"}"; do parsed_data=() parsed_data_count=0 local line="" @@ -403,7 +403,7 @@ function bashunit::runner::call_bench_functions() { fi local fn_name="" - for fn_name in "${functions_to_run[@]}"; do + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do read -r revs its max_ms <<< "$(bashunit::benchmark::parse_annotations "$fn_name" "$script")" bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" unset -v fn_name @@ -1067,9 +1067,13 @@ function bashunit::runner::record_test_hook_failure() { } function bashunit::runner::clear_mocks() { - local i=0 + if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then + return + fi + + local i for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do - bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" + bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" done } diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 372bd9e6..3c52f764 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -5,9 +5,13 @@ declare -a _BASHUNIT_MOCKED_FUNCTIONS=() function bashunit::unmock() { local command=$1 - local i=0 + if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then + return + fi + + local i for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do - if [[ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" == "$command" ]]; then + if [[ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" == "$command" ]]; then unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" unset -f "$command" local variable From de43885dc0ab5be0145648adf029a72a81b518af Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 16:58:03 +0100 Subject: [PATCH 16/41] fix: remove redundant array size check in benchmark print function The early return check was added after headers were already printed, causing incomplete output. The function already has a proper early return at the beginning (line 102-104), so this redundant check was both unnecessary and incorrect. --- src/benchmark.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/benchmark.sh b/src/benchmark.sh index 0cc22f63..8c9a7d6a 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -126,10 +126,6 @@ function bashunit::benchmark::print_results() { printf '%-40s %6s %6s %10s\n' "Name" "Revs" "Its" "Avg(ms)" fi - if [ "${#_BASHUNIT_BENCH_NAMES[@]}" -eq 0 ]; then - return - fi - local i for i in "${!_BASHUNIT_BENCH_NAMES[@]}"; do local name="${_BASHUNIT_BENCH_NAMES[$i]:-}" From a0e86ea5735260342e801ecbbf699da5913ffa03 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 21:59:53 +0100 Subject: [PATCH 17/41] fix(runner): expand test_file at trap definition time to prevent unbound variable in strict mode --- src/runner.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runner.sh b/src/runner.sh index 06b37b79..7934d5e1 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -485,7 +485,7 @@ function bashunit::runner::run_test() { local test_execution_result=$( # shellcheck disable=SC2064 - trap 'exit_code=$?; bashunit::runner::cleanup_on_exit "$test_file" "$exit_code"' EXIT + trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT bashunit::state::initialize_assertions_count # Source login shell profiles if enabled From 9c8f6219009065914cac1a8b4ce5171f50565fd7 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 10 Feb 2026 22:53:44 +0100 Subject: [PATCH 18/41] fix(compat): harden regex matching, array expansion, and parameter subs --- release.sh | 4 ++-- src/benchmark.sh | 2 ++ src/coverage.sh | 2 ++ src/env.sh | 1 + src/helpers.sh | 1 + src/main.sh | 2 ++ src/parallel.sh | 1 + src/runner.sh | 6 +++++- 8 files changed, 16 insertions(+), 3 deletions(-) diff --git a/release.sh b/release.sh index b79ef289..3e69d093 100755 --- a/release.sh +++ b/release.sh @@ -458,7 +458,7 @@ function release::sandbox::cleanup() { echo -en "${YELLOW}Keep sandbox for inspection? [y/N]: ${NC}" >&2 read -r response - if [[ "$response" =~ ^[Yy]$ ]]; then + if regex_match "$response" '^[Yy]$'; then release::log_info "Sandbox preserved at: $SANDBOX_DIR" release::log_info "To clean up later: rm -rf $SANDBOX_DIR" else @@ -757,7 +757,7 @@ function release::confirm_action() { echo -en "${YELLOW}$prompt [y/N]: ${NC}" >&2 read -r response - if [[ "$response" =~ ^[Yy]$ ]]; then + if regex_match "$response" '^[Yy]$'; then return 0 else return 1 diff --git a/src/benchmark.sh b/src/benchmark.sh index 8c9a7d6a..c1d87f04 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -62,6 +62,7 @@ function bashunit::benchmark::run_function() { local revs=$2 local its=$3 local max_ms=$4 + local IFS=$' \t\n' local -a durations=() local durations_count=0 local i r @@ -111,6 +112,7 @@ function bashunit::benchmark::print_results() { bashunit::print_line 80 "=" printf "\n" + local IFS=$' \t\n' local has_threshold=false local val for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]+"${_BASHUNIT_BENCH_MAX_MILLIS[@]}"}"; do diff --git a/src/coverage.sh b/src/coverage.sh index 6d0f954a..b3ace959 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -769,6 +769,7 @@ function bashunit::coverage::report_html() { mkdir -p "$output_dir/files" # Collect file data for index + local IFS=$' \t\n' local total_executable=0 local total_hit=0 local -a file_data=() @@ -824,6 +825,7 @@ function bashunit::coverage::generate_index_html() { local tests_failed="$7" shift 7 # Handle array passed as arguments - Bash 3.0 compatible + local IFS=$' \t\n' local -a file_data=() local file_count=0 if [[ $# -gt 0 ]]; then diff --git a/src/env.sh b/src/env.sh index 901a3afc..48e0d96a 100644 --- a/src/env.sh +++ b/src/env.sh @@ -214,6 +214,7 @@ function bashunit::env::find_terminal_width() { function bashunit::env::print_verbose() { bashunit::internal_log "Printing verbose environment variables" + local IFS=$' \t\n' # Bash 3.0 compatible: separate declaration and assignment for arrays local keys keys=( diff --git a/src/helpers.sh b/src/helpers.sh index 58ffc158..9d26fc97 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -353,6 +353,7 @@ function bashunit::helper::find_total_tests() { filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$all_fn_names") || true local count=0 + local IFS=$' \t\n' if [[ -n "$filtered_functions" ]]; then local -a functions_to_run=() # shellcheck disable=SC2206 diff --git a/src/main.sh b/src/main.sh index 7c374e95..c0e8cb75 100644 --- a/src/main.sh +++ b/src/main.sh @@ -5,6 +5,7 @@ ############################# function bashunit::main::cmd_test() { local filter="" + local IFS=$' \t\n' local -a raw_args=() local raw_args_count=0 local -a args=() @@ -259,6 +260,7 @@ function bashunit::main::cmd_test() { ############################# function bashunit::main::cmd_bench() { local filter="" + local IFS=$' \t\n' local -a raw_args=() local raw_args_count=0 local -a args=() diff --git a/src/parallel.sh b/src/parallel.sh index 694c2136..79b17087 100755 --- a/src/parallel.sh +++ b/src/parallel.sh @@ -2,6 +2,7 @@ function bashunit::parallel::aggregate_test_results() { local temp_dir_parallel_test_suite=$1 + local IFS=$' \t\n' bashunit::internal_log "aggregate_test_results" "dir:$temp_dir_parallel_test_suite" diff --git a/src/runner.sh b/src/runner.sh index 7934d5e1..ca9d576c 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -15,6 +15,7 @@ function bashunit::runner::restore_workdir() { function bashunit::runner::load_test_files() { local filter=$1 shift + local IFS=$' \t\n' local -a files=() [[ $# -gt 0 ]] && files=("$@") local -a scripts_ids=() @@ -207,6 +208,7 @@ function bashunit::runner::parse_data_provider_args() { local had_quotes=false # Track if arg was quoted (to preserve empty quoted strings) local quote_char="" local escaped=false + local IFS=$' \t\n' local i=0 local arg="" local encoded_arg @@ -313,6 +315,7 @@ function bashunit::runner::parse_data_provider_args() { function bashunit::runner::call_test_functions() { local script="$1" local filter="$2" + local IFS=$' \t\n' local prefix="test" # Use cached function names for better performance local filtered_functions @@ -379,6 +382,7 @@ function bashunit::runner::call_test_functions() { function bashunit::runner::call_bench_functions() { local script="$1" local filter="$2" + local IFS=$' \t\n' local prefix="bench" # Use cached function names for better performance @@ -580,7 +584,7 @@ function bashunit::runner::run_test() { "cannot execute binary file" "invalid arithmetic operator"; do if [[ "$runtime_output" == *"$error"* ]]; then runtime_error="${runtime_output#*: }" # Remove everything up to and including ": " - runtime_error="${runtime_error//$'\n'/}" # Remove all newlines using parameter expansion + runtime_error=${runtime_error//$'\n'/} # Remove all newlines using parameter expansion break fi done From ec935c2f825d2c8d9325711b47cc37c77622a685 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 11 Feb 2026 00:11:25 +0100 Subject: [PATCH 19/41] revert: remove unnecessary loop variable initializations from d7cd950 --- src/colors.sh | 2 +- src/console_results.sh | 2 +- src/coverage.sh | 10 +++++----- src/doc.sh | 2 +- src/env.sh | 2 +- src/globals.sh | 2 +- src/runner.sh | 26 +++++++++++++------------- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/colors.sh b/src/colors.sh index dfd1208f..3ce77abc 100644 --- a/src/colors.sh +++ b/src/colors.sh @@ -10,7 +10,7 @@ bashunit::sgr() { local codes=${1:-0} shift - local c="" + local c for c in "$@"; do codes="$codes;$c" done diff --git a/src/console_results.sh b/src/console_results.sh index 91cd2510..9d3242ff 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -351,7 +351,7 @@ function bashunit::console_results::print_error_test() { if [[ -n "$raw_output" ]] && bashunit::env::is_show_output_on_failure_enabled; then line="$line$(printf " %sOutput:%s\n" "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}")" - local output_line="" + local output_line while IFS= read -r output_line; do line="$line$(printf " %s\n" "$output_line")" done <<< "$raw_output" diff --git a/src/coverage.sh b/src/coverage.sh index b3ace959..8e7024fa 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -384,7 +384,7 @@ function bashunit::coverage::is_executable_line() { function bashunit::coverage::get_executable_lines() { local file="$1" local count=0 - local lineno=0 + local lineno local line while IFS= read -r line || [[ -n "$line" ]]; do @@ -416,7 +416,7 @@ function bashunit::coverage::get_hit_lines() { # Only count hits that correspond to executable lines # This prevents >100% coverage when DEBUG trap fires on non-executable lines local count=0 - local line_num=0 + local line_num for line_num in $hit_lines; do local line_content line_content=$(sed -n "${line_num}p" "$file" 2>/dev/null) || continue @@ -616,7 +616,7 @@ function bashunit::coverage::report_text() { echo "Coverage Report" echo "---------------" - local file="" + local file while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue has_files=true @@ -788,7 +788,7 @@ function bashunit::coverage::report_html() { total_hit=$((total_hit + hit)) local display_file="${file#"$(pwd)"/}" - local safe_filename="" + local safe_filename safe_filename=$(bashunit::coverage::path_to_filename "$file") file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename"; file_data_count=$((file_data_count + 1)) @@ -1194,7 +1194,7 @@ function bashunit::coverage::generate_file_html() { # Index: line number, Value: newline-separated list of "test_file:test_function" # Using indexed array for Bash 3.0 compatibility (no associative arrays) local -a tests_by_line=() - local _line_and_test="" + local _line_and_test while IFS= read -r _line_and_test; do [[ -z "$_line_and_test" ]] && continue local _tln="${_line_and_test%%|*}" diff --git a/src/doc.sh b/src/doc.sh index 0eaaf46e..0c4f7e42 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -18,7 +18,7 @@ function bashunit::doc::print_asserts() { # Pattern stored in variable for Bash 3.0 compatibility local _doc_pattern='^## ([A-Za-z0-9_]+)' - local line="" + local line while IFS='' read -r line || [[ -n "$line" ]]; do if [[ $line =~ $_doc_pattern ]]; then fn="${BASH_REMATCH[1]}" diff --git a/src/env.sh b/src/env.sh index 48e0d96a..7aab91a3 100644 --- a/src/env.sh +++ b/src/env.sh @@ -245,7 +245,7 @@ function bashunit::env::print_verbose() { local max_length=0 - local key="" + local key for key in "${keys[@]+"${keys[@]}"}"; do if (( ${#key} > max_length )); then max_length=${#key} diff --git a/src/globals.sh b/src/globals.sh index 3298fc74..41df1a40 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -31,7 +31,7 @@ function bashunit::random_str() { local length=${1:-6} local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local str='' - local i=0 + local i for (( i=0; i/dev/null printf "\r \r" # Clear the spinner output - local script_id="" + local script_id for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" bashunit::cleanup_script_temp_files @@ -117,7 +117,7 @@ function bashunit::runner::load_bench_files() { local -a files=() [[ $# -gt 0 ]] && files=("$@") - local bench_file="" + local bench_file for bench_file in ${files+"${files[@]}"}; do [[ -f $bench_file ]] || continue unset BASHUNIT_CURRENT_TEST_ID @@ -140,7 +140,7 @@ function bashunit::runner::load_bench_files() { # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) - local i=0 + local i for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done @@ -179,7 +179,7 @@ function bashunit::runner::spinner() { local delay=0.1 local spin_chars="|/-\\" while true; do - local i=0 + local i for ((i=0; i<${#spin_chars}; i++)); do printf "\r%s" "${spin_chars:$i:1}" sleep "$delay" @@ -243,7 +243,7 @@ function bashunit::runner::parse_data_provider_args() { fi # Fallback: parse args from the input string into an array, respecting quotes and escapes - local i=0 + local i for ((i=0; i<${#input}; i++)); do local char="${input:$i:1}" if [ "$escaped" = true ]; then @@ -348,7 +348,7 @@ function bashunit::runner::call_test_functions() { provider_data=() provider_data_count=0 - local line="" + local line while IFS=" " read -r line; do [[ -z "$line" ]] && continue provider_data[provider_data_count]="$line" @@ -363,11 +363,11 @@ function bashunit::runner::call_test_functions() { fi # Execute the test function for each line of data - local data="" + local data for data in "${provider_data[@]+"${provider_data[@]}"}"; do parsed_data=() parsed_data_count=0 - local line="" + local line while IFS= read -r line; do [[ -z "$line" ]] && continue parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" @@ -406,7 +406,7 @@ function bashunit::runner::call_bench_functions() { bashunit::runner::render_running_file_header "$script" fi - local fn_name="" + local fn_name for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do read -r revs its max_ms <<< "$(bashunit::benchmark::parse_annotations "$fn_name" "$script")" bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" @@ -938,7 +938,7 @@ function bashunit::runner::execute_file_hook() { if [[ -f "$hook_output_file" ]]; then hook_output="" - local line="" + local line while IFS= read -r line; do [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done < "$hook_output_file" @@ -1029,7 +1029,7 @@ function bashunit::runner::execute_test_hook() { if [[ -f "$hook_output_file" ]]; then hook_output="" - local line="" + local line while IFS= read -r line; do [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done < "$hook_output_file" From 421fbeebb0e37ba8931e65ee5f0de99fca8eb09e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 11 Feb 2026 00:33:41 +0100 Subject: [PATCH 20/41] fix(coverage): initialize lineno counter to prevent strict mode error --- src/coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coverage.sh b/src/coverage.sh index 8e7024fa..f62f0906 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -384,7 +384,7 @@ function bashunit::coverage::is_executable_line() { function bashunit::coverage::get_executable_lines() { local file="$1" local count=0 - local lineno + local lineno=0 local line while IFS= read -r line || [[ -n "$line" ]]; do From e9be71bffb03d4aefaa9a2ac59a8f69a487cd802 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 11 Feb 2026 11:29:17 +0100 Subject: [PATCH 21/41] fix(compat): address PR feedback for Bash 3.0 compatibility - Remove duplicate local declaration in doc.sh - Fix uninitialized array variables in assert.sh by using safe initialization pattern - Clarify IFS usage in coverage.sh by moving to function start - Remove unnecessary count initialization in coverage.sh - Format all files with shfmt --- bashunit | 86 ++-- bin/create-pr | 118 ++--- bin/pre-commit | 8 +- build.sh | 32 +- example/custom_functions.sh | 2 +- install.sh | 6 +- release.sh | 126 +++--- src/assert.sh | 102 +++-- src/assert_arrays.sh | 6 +- src/assert_files.sh | 8 +- src/assert_snapshot.sh | 4 +- src/benchmark.sh | 9 +- src/check_os.sh | 28 +- src/clock.sh | 83 ++-- src/console_header.sh | 100 ++--- src/console_results.sh | 37 +- src/coverage.sh | 197 ++++---- src/dependencies.sh | 2 +- src/doc.sh | 7 +- src/env.sh | 8 +- src/globals.sh | 25 +- src/helpers.sh | 136 +++--- src/init.sh | 4 +- src/learn.sh | 52 +-- src/main.sh | 420 +++++++++--------- src/math.sh | 2 +- src/parallel.sh | 22 +- src/reports.sh | 10 +- src/runner.sh | 127 +++--- src/state.sh | 56 +-- src/str.sh | 6 +- src/test_doubles.sh | 4 +- .../bashunit_assert_subcommand_test.sh | 126 +++--- .../bashunit_bootstrap_args_test.sh | 36 +- ...bashunit_cd_in_setup_before_script_test.sh | 30 +- .../acceptance/bashunit_custom_title_test.sh | 14 +- .../bashunit_direct_fn_call_test.sh | 128 +++--- .../bashunit_execution_error_test.sh | 60 +-- tests/acceptance/bashunit_exit_code_test.sh | 8 +- tests/acceptance/bashunit_fail_test.sh | 44 +- .../acceptance/bashunit_failures_only_test.sh | 68 +-- .../bashunit_find_tests_command_line_test.sh | 18 +- tests/acceptance/bashunit_init_test.sh | 52 +-- .../acceptance/bashunit_inline_filter_test.sh | 68 +-- .../bashunit_lifecycle_output_test.sh | 112 ++--- tests/acceptance/bashunit_log_junit_test.sh | 20 +- tests/acceptance/bashunit_login_shell_test.sh | 26 +- tests/acceptance/bashunit_no_color_test.sh | 32 +- tests/acceptance/bashunit_no_output_test.sh | 24 +- tests/acceptance/bashunit_no_progress_test.sh | 78 ++-- .../bashunit_parallel_consistency_test.sh | 26 +- tests/acceptance/bashunit_pass_test.sh | 32 +- tests/acceptance/bashunit_path_test.sh | 24 +- tests/acceptance/bashunit_report_html_test.sh | 28 +- .../bashunit_run_all_assertions_test.sh | 56 +-- .../bashunit_script_temp_file_cleanup_test.sh | 84 ++-- ...bashunit_setup_before_script_error_test.sh | 192 ++++---- tests/acceptance/bashunit_setup_error_test.sh | 166 +++---- .../bashunit_show_output_on_failure_test.sh | 56 +-- .../acceptance/bashunit_skip_env_file_test.sh | 42 +- .../bashunit_stop_on_failure_test.sh | 24 +- tests/acceptance/bashunit_strict_mode_test.sh | 44 +- .../bashunit_summary_output_test.sh | 48 +- ...shunit_teardown_after_script_error_test.sh | 144 +++--- .../bashunit_teardown_error_test.sh | 96 ++-- tests/acceptance/bashunit_test.sh | 61 ++- tests/acceptance/bashunit_upgrade_test.sh | 148 +++--- .../fixtures/script_with_setup_temp_file.sh | 14 +- .../fixtures/strict_mode_nonzero_return.sh | 10 +- .../fixtures/strict_mode_unset_variable.sh | 10 +- .../test_bashunit_run_all_assertions.sh | 14 +- .../test_bashunit_setup_stops_on_failure.sh | 10 +- .../test_bashunit_show_output_on_failure.sh | 6 +- .../test_bashunit_when_a_execution_error.sh | 2 +- .../test_bashunit_when_a_test_fail.sh | 14 +- .../test_bashunit_when_a_test_passes.sh | 12 +- ...t_bashunit_when_a_test_returns_non_zero.sh | 2 +- ..._exit_immediately_after_execution_error.sh | 4 +- .../fixtures/test_bashunit_when_log_junit.sh | 4 +- .../test_bashunit_when_report_html.sh | 8 +- ...ashunit_when_setup_before_script_errors.sh | 4 +- ...before_script_fails_with_multiple_tests.sh | 6 +- ...etup_before_script_with_failing_command.sh | 4 +- ...cript_with_intermediate_failing_command.sh | 6 +- .../test_bashunit_when_setup_errors.sh | 4 +- ...ashunit_when_setup_with_failing_command.sh | 4 +- ...setup_with_intermediate_failing_command.sh | 6 +- .../test_bashunit_when_stop_on_failure.sh | 10 +- ...hunit_when_teardown_after_script_errors.sh | 4 +- ...rdown_after_script_with_failing_command.sh | 4 +- ...cript_with_intermediate_failing_command.sh | 6 +- .../test_bashunit_when_teardown_errors.sh | 4 +- ...unit_when_teardown_with_failing_command.sh | 4 +- ...st_bashunit_with_multiple_failing_tests.sh | 14 +- .../fixtures/test_bootstrap_args.sh | 6 +- .../test_cd_in_setup_before_script_first.sh | 4 +- .../test_cd_in_setup_before_script_second.sh | 2 +- .../acceptance/fixtures/test_custom_title.sh | 6 +- .../fixtures/test_parallel_spy_file1.sh | 6 +- .../fixtures/test_parallel_spy_file2.sh | 6 +- .../acceptance/fixtures/tests_path/a_test.sh | 6 +- .../fixtures/tests_path/other_test.sh | 6 +- tests/acceptance/install_test.sh | 239 +++++----- tests/acceptance/mock_test.sh | 14 +- .../acceptance/parallel_spy_parallel_test.sh | 8 +- tests/bootstrap.sh | 7 +- tests/functional/custom_asserts_test.sh | 6 +- tests/unit/assert_snapshot_test.sh | 4 +- tests/unit/assert_test.sh | 156 +++---- tests/unit/clock_test.sh | 24 +- tests/unit/console_results_test.sh | 35 +- tests/unit/coverage_test.sh | 26 +- tests/unit/custom_assertions_test.sh | 5 +- tests/unit/directory_test.sh | 85 ++-- tests/unit/file_test.sh | 47 +- tests/unit/fixtures/fake_function_to_spy.sh | 2 +- tests/unit/globals_test.sh | 2 +- tests/unit/helpers_test.sh | 17 +- tests/unit/parallel_test.sh | 2 +- tests/unit/redirect_error_test.sh | 6 +- tests/unit/release_test.sh | 20 +- tests/unit/setup_teardown_test.sh | 8 +- tests/unit/skip_todo_test.sh | 9 +- tests/unit/state_test.sh | 10 +- tests/unit/test_doubles_test.sh | 29 +- 125 files changed, 2492 insertions(+), 2549 deletions(-) diff --git a/bashunit b/bashunit index 12bda3d5..a7582db5 100755 --- a/bashunit +++ b/bashunit @@ -17,9 +17,9 @@ function _check_bash_version() { fi local major - IFS=. read -r major _ <<< "$current_version" + IFS=. read -r major _ <<<"$current_version" - if (( major < 3 )); then + if ((major < 3)); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 exit 1 fi @@ -41,16 +41,16 @@ export BASHUNIT_WORKING_DIR # Early scan for flags that must be set before loading env.sh for arg in "$@"; do case "$arg" in - --skip-env-file) - export BASHUNIT_SKIP_ENV_FILE=true - ;; - -l|--login) - export BASHUNIT_LOGIN_SHELL=true - ;; - --no-color) - # shellcheck disable=SC2034 - BASHUNIT_NO_COLOR=true - ;; + --skip-env-file) + export BASHUNIT_SKIP_ENV_FILE=true + ;; + -l | --login) + export BASHUNIT_LOGIN_SHELL=true + ;; + --no-color) + # shellcheck disable=SC2034 + BASHUNIT_NO_COLOR=true + ;; esac done @@ -88,39 +88,39 @@ bashunit::clock::init _SUBCOMMAND="" case "${1:-}" in - test|bench|doc|init|learn|upgrade|assert) - _SUBCOMMAND="$1" - shift - ;; - -v|--version) - bashunit::console_header::print_version - exit 0 - ;; - -h|--help) - bashunit::console_header::print_help - exit 0 - ;; - -*) - # Flag without subcommand → assume "test" - _SUBCOMMAND="test" - ;; - "") - # No arguments → assume "test" (uses BASHUNIT_DEFAULT_PATH) - _SUBCOMMAND="test" - ;; - *) - # Path argument → assume "test" - _SUBCOMMAND="test" - ;; +test | bench | doc | init | learn | upgrade | assert) + _SUBCOMMAND="$1" + shift + ;; +-v | --version) + bashunit::console_header::print_version + exit 0 + ;; +-h | --help) + bashunit::console_header::print_help + exit 0 + ;; +-*) + # Flag without subcommand → assume "test" + _SUBCOMMAND="test" + ;; +"") + # No arguments → assume "test" (uses BASHUNIT_DEFAULT_PATH) + _SUBCOMMAND="test" + ;; +*) + # Path argument → assume "test" + _SUBCOMMAND="test" + ;; esac # Route to subcommand handler case "$_SUBCOMMAND" in - test) bashunit::main::cmd_test "$@" ;; - bench) bashunit::main::cmd_bench "$@" ;; - doc) bashunit::main::cmd_doc "$@" ;; - init) bashunit::main::cmd_init "$@" ;; - learn) bashunit::main::cmd_learn "$@" ;; - upgrade) bashunit::main::cmd_upgrade "$@" ;; - assert) bashunit::main::cmd_assert "$@" ;; +test) bashunit::main::cmd_test "$@" ;; +bench) bashunit::main::cmd_bench "$@" ;; +doc) bashunit::main::cmd_doc "$@" ;; +init) bashunit::main::cmd_init "$@" ;; +learn) bashunit::main::cmd_learn "$@" ;; +upgrade) bashunit::main::cmd_upgrade "$@" ;; +assert) bashunit::main::cmd_assert "$@" ;; esac diff --git a/bin/create-pr b/bin/create-pr index 5560422e..be05d558 100755 --- a/bin/create-pr +++ b/bin/create-pr @@ -21,7 +21,7 @@ function console_header::print_version() { } function console_header::print_help() { - cat </dev/null)"} \ - || error_and_exit "Failed to get the current branch name." +CURRENT_BRANCH=${CURRENT_BRANCH:-"$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"} || + error_and_exit "Failed to get the current branch name." REMOTE_URL=${REMOTE_URL:-"$(git config --get remote.origin.url)"} if [[ "$REMOTE_URL" == *"github.com"* ]]; then @@ -121,8 +121,8 @@ function main::create_pr() { # Push the current branch if ! git push -u origin "$CURRENT_BRANCH"; then - error_and_exit "Failed to push the current branch to the remote repository."\ - "Please check your git remote settings." + error_and_exit "Failed to push the current branch to the remote repository." \ + "Please check your git remote settings." fi if [[ "$PR_USING_CLIENT" == "gitlab" ]]; then @@ -137,13 +137,13 @@ function main::create_pr_gitlab() { local glab_command=( glab mr create - --title "$PR_TITLE" - --target-branch "$TARGET_BRANCH" - --source-branch "$CURRENT_BRANCH" - --assignee "$PR_ASSIGNEE" - --reviewer "$PR_REVIEWER" - --label "$PR_LABEL" - --description "$PR_BODY" + --title "$PR_TITLE" + --target-branch "$TARGET_BRANCH" + --source-branch "$CURRENT_BRANCH" + --assignee "$PR_ASSIGNEE" + --reviewer "$PR_REVIEWER" + --label "$PR_LABEL" + --description "$PR_BODY" ) if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then @@ -163,13 +163,13 @@ function main::create_pr_github() { local gh_command=( gh pr create - --title "$PR_TITLE" - --base "$TARGET_BRANCH" - --head "$CURRENT_BRANCH" - --assignee "$PR_ASSIGNEE" - --reviewer "$PR_REVIEWER" - --label "$PR_LABEL" - --body "$PR_BODY" + --title "$PR_TITLE" + --base "$TARGET_BRANCH" + --head "$CURRENT_BRANCH" + --assignee "$PR_ASSIGNEE" + --reviewer "$PR_REVIEWER" + --label "$PR_LABEL" + --body "$PR_BODY" ) if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then @@ -177,8 +177,8 @@ function main::create_pr_github() { fi if ! "${gh_command[@]}"; then - error_and_exit "Failed to create the Pull Request." \ - "Ensure you have the correct permissions and the repository is properly configured." + error_and_exit "Failed to create the Pull Request." \ + "Ensure you have the correct permissions and the repository is properly configured." fi main::run_after_creation_script @@ -287,7 +287,7 @@ function pr_label() { IFS=';' # Split mapping entries by semicolon for entry in $mapping; do # Split each entry into keys and value - IFS=':' read -r keys value <<< "$entry" + IFS=':' read -r keys value <<<"$entry" # Check if the prefix matches any of the keys IFS='|' # Split keys by pipe symbol @@ -388,7 +388,7 @@ function pr_title() { fi local title - title=$(echo "$branch_name" | cut -d'-' -f3- | tr '-' ' '| tr '_' ' ') + title=$(echo "$branch_name" | cut -d'-' -f3- | tr '-' ' ' | tr '_' ' ') title="$(echo "${title:0:1}" | tr '[:lower:]' '[:upper:]')${title:1}" # Normalize the template by removing spaces around placeholders @@ -404,7 +404,7 @@ function pr_title() { if [[ -n "$PR_TITLE_REMOVE_PREFIX" ]]; then # Split PR_TITLE_REMOVE_PREFIX into an array - IFS=',' read -ra prefixes <<< "$PR_TITLE_REMOVE_PREFIX" + IFS=',' read -ra prefixes <<<"$PR_TITLE_REMOVE_PREFIX" # Loop through each prefix and remove it from the start if it matches for prefix in "${prefixes[@]}"; do # shellcheck disable=SC2001 @@ -412,9 +412,9 @@ function pr_title() { done # Trim leading whitespace and capitalize the first letter - new_title=$(echo "$new_title" \ - | sed 's/^ *//' \ - | awk '{ print toupper(substr($0,1,1)) tolower(substr($0,2)) }') + new_title=$(echo "$new_title" | + sed 's/^ *//' | + awk '{ print toupper(substr($0,1,1)) tolower(substr($0,2)) }') fi formatted="${formatted//\{\{PR_TITLE\}\}/$new_title}" @@ -450,8 +450,8 @@ GH_CLI_INSTALLATION_URL="https://cli.github.com/" GLAB_CLI_INSTALLATION_URL="https://gitlab.com/gitlab-org/cli/" function error_and_exit() { - echo "Error: $1" >&2 - exit 1 + echo "Error: $1" >&2 + exit 1 } function validate::target_branch_exists() { @@ -473,13 +473,13 @@ function validate::current_branch_is_not_target() { } function validate::gh_cli_is_installed() { - if ! command -v gh &> /dev/null; then + if ! command -v gh &>/dev/null; then error_and_exit "gh CLI is not installed. Please install it from $GH_CLI_INSTALLATION_URL and try again." fi } function validate::glab_cli_is_installed() { - if ! command -v glab &> /dev/null; then + if ! command -v glab &>/dev/null; then error_and_exit "glab CLI is not installed. Please install it from $GLAB_CLI_INSTALLATION_URL and try again." fi } @@ -493,38 +493,38 @@ declare -r CREATE_PR_VERSION="0.10.0" CREATE_PR_ROOT_DIR="$(dirname "${BASH_SOURCE[0]}")" export CREATE_PR_ROOT_DIR - DRY_RUN=${DRY_RUN:-false} EXTRA_ARGS=() while [[ $# -gt 0 ]]; do argument="$1" case $argument in - --debug) - set -x - ;; - --dry-run) - DRY_RUN=true - ;; - -e|--env) - # shellcheck disable=SC1090 - source "$2" - shift - ;; - -t|--title) - helpers::generate_branch_name "$2" "${3:-}" - trap '' EXIT && exit 0 - ;; - -h|--help) - console_header::print_help - trap '' EXIT && exit 0 - ;; - -v|--version) - console_header::print_version - trap '' EXIT && exit 0 - ;; - *) - EXTRA_ARGS+=("$argument") + --debug) + set -x + ;; + --dry-run) + DRY_RUN=true + ;; + -e | --env) + # shellcheck disable=SC1090 + source "$2" + shift + ;; + -t | --title) + helpers::generate_branch_name "$2" "${3:-}" + trap '' EXIT && exit 0 + ;; + -h | --help) + console_header::print_help + trap '' EXIT && exit 0 + ;; + -v | --version) + console_header::print_version + trap '' EXIT && exit 0 + ;; + *) + EXTRA_ARGS+=("$argument") + ;; esac shift done diff --git a/bin/pre-commit b/bin/pre-commit index f0f1e813..39a57cf0 100755 --- a/bin/pre-commit +++ b/bin/pre-commit @@ -5,9 +5,9 @@ make pre_commit/run EXIT_CODE=$? if [[ ${EXIT_CODE} -ne 0 ]]; then - echo "Pre Commit checks failed. Please fix the above issues before committing" - exit ${EXIT_CODE} + echo "Pre Commit checks failed. Please fix the above issues before committing" + exit ${EXIT_CODE} else - echo "Pre Commit checks passed, no problems found" - exit 0 + echo "Pre Commit checks passed, no problems found" + exit 0 fi diff --git a/build.sh b/build.sh index 83aeaa6a..1987de9b 100755 --- a/build.sh +++ b/build.sh @@ -39,15 +39,15 @@ function build::generate_bin() { local temp temp="$(dirname "$out")/temp.sh" - echo '#!/usr/bin/env bash' > "$temp" + echo '#!/usr/bin/env bash' >"$temp" echo "Generating bashunit in the '$(dirname "$out")' folder..." for file in $(build::dependencies); do build::process_file "$file" "$temp" done - cat bashunit >> "$temp" - grep -v '^source' "$temp" > "$out" + cat bashunit >>"$temp" + grep -v '^source' "$temp" >"$out" rm "$temp" chmod u+x "$out" @@ -62,9 +62,9 @@ function build::process_file() { { echo "# $(basename "$file")" - tail -n +2 "$file" >> "$temp" + tail -n +2 "$file" >>"$temp" echo "" - } >> "$temp" + } >>"$temp" # Search for any 'source' lines in the current file grep '^source ' "$file" | while read -r line; do @@ -139,7 +139,7 @@ function build::embed_docs() { # Print everything after the end marker sed -n '/# __BASHUNIT_EMBEDDED_DOCS_END__/,$p' "$file" | tail -n +2 - } > "$temp_file" + } >"$temp_file" mv "$temp_file" "$file" chmod u+x "$file" @@ -159,7 +159,7 @@ function build::generate_checksum() { checksum=$(sha256sum "$out") fi - echo "$checksum" > "$(dirname "$out")/checksum" + echo "$checksum" >"$(dirname "$out")/checksum" echo "$checksum" } @@ -173,15 +173,15 @@ SHOULD_CLEANUP=false for arg in "$@"; do case $arg in - -v|--verify) - SHOULD_VERIFY_BUILD=true - ;; - -c|--cleanup) - SHOULD_CLEANUP=true - ;; - *) - DIR=$arg - ;; + -v | --verify) + SHOULD_VERIFY_BUILD=true + ;; + -c | --cleanup) + SHOULD_CLEANUP=true + ;; + *) + DIR=$arg + ;; esac done diff --git a/example/custom_functions.sh b/example/custom_functions.sh index a0b1a9f3..7d363cac 100755 --- a/example/custom_functions.sh +++ b/example/custom_functions.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -function say_hi(){ +function say_hi() { echo "Hi, $1!" } diff --git a/install.sh b/install.sh index 17a6d1f3..80411e34 100755 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ function regex_match() { } function is_git_installed() { - command -v git > /dev/null 2>&1 + command -v git >/dev/null 2>&1 } function build_and_install_beta() { @@ -44,9 +44,9 @@ function install() { echo "> Downloading the latest version: '$TAG'" fi - if command -v curl > /dev/null 2>&1; then + if command -v curl >/dev/null 2>&1; then curl -L -O -J "$BASHUNIT_GIT_REPO/releases/download/$TAG/bashunit" 2>/dev/null - elif command -v wget > /dev/null 2>&1; then + elif command -v wget >/dev/null 2>&1; then wget "$BASHUNIT_GIT_REPO/releases/download/$TAG/bashunit" 2>/dev/null else echo "Cannot download bashunit: curl or wget not found." diff --git a/release.sh b/release.sh index 3e69d093..32a33e0e 100755 --- a/release.sh +++ b/release.sh @@ -396,19 +396,19 @@ function release::sandbox::mock_gh() { gh() { release::log_sandbox "Would execute: gh $*" case "$1" in - release) - release::log_sandbox "GitHub release would be created" - return 0 - ;; - api) - # Return empty for contributor lookup - echo "" - return 0 - ;; - auth) - # Auth status check - return success in sandbox - return 0 - ;; + release) + release::log_sandbox "GitHub release would be created" + return 0 + ;; + api) + # Return empty for contributor lookup + echo "" + return 0 + ;; + auth) + # Auth status check - return success in sandbox + return 0 + ;; esac return 0 } @@ -509,7 +509,7 @@ function release::sandbox::run() { # Generate release notes RELEASE_NOTES_FILE="/tmp/bashunit-release-notes-${VERSION}.md" CHECKSUM=$(release::get_checksum) - release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" > "$RELEASE_NOTES_FILE" + release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" >"$RELEASE_NOTES_FILE" release::log_success "Generated release notes" # Show what would happen with push/gh release @@ -557,10 +557,10 @@ function release::version_gt() { local i local ver1 local ver2 - IFS=. read -ra ver1 <<< "$v1" - IFS=. read -ra ver2 <<< "$v2" + IFS=. read -ra ver1 <<<"$v1" + IFS=. read -ra ver2 <<<"$v2" - for ((i=0; i<3; i++)); do + for ((i = 0; i < 3; i++)); do if ((ver1[i] > ver2[i])); then return 0 elif ((ver1[i] < ver2[i])); then @@ -657,10 +657,10 @@ function release::generate_release_notes() { # Extract content from the latest version header (first ## [) until the next version header # Transform changelog sections to release format with emojis - awk '/^## \[/{if(found) exit; found=1; next} found' CHANGELOG.md | \ - sed 's/^### Added$/## ✨ Improvements/' | \ - sed 's/^### Changed$/## 🛠️ Changes/' | \ - sed 's/^### Fixed$/## 🐛 Bug Fixes/' | \ + awk '/^## \[/{if(found) exit; found=1; next} found' CHANGELOG.md | + sed 's/^### Added$/## ✨ Improvements/' | + sed 's/^### Changed$/## 🛠️ Changes/' | + sed 's/^### Fixed$/## 🐛 Bug Fixes/' | sed 's/^### Performance$/## ⚡ Performance/' # Add contributors section @@ -871,48 +871,48 @@ function release::main() { # Parse arguments while [[ $# -gt 0 ]]; do case $1 in - --dry-run) - DRY_RUN=true - shift - ;; - --sandbox) - SANDBOX_MODE=true - shift - ;; - --force) - FORCE_MODE=true - shift - ;; - --verbose) - VERBOSE_MODE=true - shift - ;; - --json) - JSON_OUTPUT=true - shift - ;; - --without-gh-release) - WITH_GH_RELEASE=false - shift - ;; - --rollback) - release::rollback::manual - exit $? - ;; - -h|--help) + --dry-run) + DRY_RUN=true + shift + ;; + --sandbox) + SANDBOX_MODE=true + shift + ;; + --force) + FORCE_MODE=true + shift + ;; + --verbose) + VERBOSE_MODE=true + shift + ;; + --json) + JSON_OUTPUT=true + shift + ;; + --without-gh-release) + WITH_GH_RELEASE=false + shift + ;; + --rollback) + release::rollback::manual + exit $? + ;; + -h | --help) + release::show_usage + exit $EXIT_SUCCESS + ;; + *) + if [[ -z "$VERSION" ]]; then + VERSION=$1 + else + release::log_error "Unknown argument: $1" release::show_usage - exit $EXIT_SUCCESS - ;; - *) - if [[ -z "$VERSION" ]]; then - VERSION=$1 - else - release::log_error "Unknown argument: $1" - release::show_usage - exit $EXIT_VALIDATION_ERROR - fi - shift - ;; + exit $EXIT_VALIDATION_ERROR + fi + shift + ;; esac done @@ -1006,7 +1006,7 @@ function release::main() { release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" >&2 echo "----------------------------------------" >&2 else - release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" > "$RELEASE_NOTES_FILE" + release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" >"$RELEASE_NOTES_FILE" release::log_success "Saved release notes to $RELEASE_NOTES_FILE" fi release::state::record_step "generate_release_notes" diff --git a/src/assert.sh b/src/assert.sh index dde5fc86..6d9c33f1 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -8,7 +8,7 @@ function bashunit::assert::mark_failed() { # Guard clause to skip assertion if one already failed in test (when stop-on-assertion is enabled) function bashunit::assert::should_skip() { - bashunit::env::is_stop_on_assertion_failure_enabled && (( _BASHUNIT_ASSERTION_FAILED_IN_TEST )) + bashunit::env::is_stop_on_assertion_failure_enabled && ((_BASHUNIT_ASSERTION_FAILED_IN_TEST)) } function bashunit::fail() { @@ -31,8 +31,14 @@ function assert_true() { # Check for expected literal values first case "$actual" in - "true"|"0") bashunit::state::add_assertions_passed; return ;; - "false"|"1") bashunit::handle_bool_assertion_failure "true or 0" "$actual"; return ;; + "true" | "0") + bashunit::state::add_assertions_passed + return + ;; + "false" | "1") + bashunit::handle_bool_assertion_failure "true or 0" "$actual" + return + ;; esac # Run command or eval and check the exit code @@ -53,8 +59,14 @@ function assert_false() { # Check for expected literal values first case "$actual" in - "false"|"1") bashunit::state::add_assertions_passed; return ;; - "true"|"0") bashunit::handle_bool_assertion_failure "false or 1" "$actual"; return ;; + "false" | "1") + bashunit::state::add_assertions_passed + return + ;; + "true" | "0") + bashunit::handle_bool_assertion_failure "false or 1" "$actual" + return + ;; esac # Run command or eval and check the exit code @@ -72,11 +84,11 @@ function bashunit::run_command_or_eval() { local cmd="$1" if [[ "$cmd" =~ ^eval ]]; then - eval "${cmd#eval }" &> /dev/null + eval "${cmd#eval }" &>/dev/null elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then - eval "$cmd" &> /dev/null + eval "$cmd" &>/dev/null else - "$cmd" &> /dev/null + "$cmd" &>/dev/null fi return $? } @@ -219,7 +231,8 @@ function assert_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -266,7 +279,8 @@ function assert_not_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -287,7 +301,8 @@ function assert_matches() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -308,7 +323,8 @@ function assert_not_matches() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -339,23 +355,23 @@ function assert_exec() { while [[ $# -gt 0 ]]; do case "$1" in - --exit) - expected_exit="$2" - shift 2 - ;; - --stdout) - expected_stdout="$2" - check_stdout=true - shift 2 - ;; - --stderr) - expected_stderr="$2" - check_stderr=true - shift 2 - ;; - *) - shift - ;; + --exit) + expected_exit="$2" + shift 2 + ;; + --stdout) + expected_stdout="$2" + check_stdout=true + shift 2 + ;; + --stderr) + expected_stderr="$2" + check_stderr=true + shift 2 + ;; + *) + shift + ;; esac done @@ -411,7 +427,7 @@ function assert_exec() { } function assert_exit_code() { - local actual_exit_code=${3-"$?"} # Capture $? before guard check + local actual_exit_code=${3-"$?"} # Capture $? before guard check bashunit::assert::should_skip && return 0 local expected_exit_code="$1" @@ -430,7 +446,7 @@ function assert_exit_code() { } function assert_successful_code() { - local actual_exit_code=${3-"$?"} # Capture $? before guard check + local actual_exit_code=${3-"$?"} # Capture $? before guard check bashunit::assert::should_skip && return 0 local expected_exit_code=0 @@ -450,7 +466,7 @@ function assert_successful_code() { } function assert_unsuccessful_code() { - local actual_exit_code=${3-"$?"} # Capture $? before guard check + local actual_exit_code=${3-"$?"} # Capture $? before guard check bashunit::assert::should_skip && return 0 if [[ "$actual_exit_code" -eq 0 ]]; then @@ -467,7 +483,7 @@ function assert_unsuccessful_code() { } function assert_general_error() { - local actual_exit_code=${3-"$?"} # Capture $? before guard check + local actual_exit_code=${3-"$?"} # Capture $? before guard check bashunit::assert::should_skip && return 0 local expected_exit_code=1 @@ -487,7 +503,7 @@ function assert_general_error() { } function assert_command_not_found() { - local actual_exit_code=${3-"$?"} # Capture $? before guard check + local actual_exit_code=${3-"$?"} # Capture $? before guard check bashunit::assert::should_skip && return 0 local expected_exit_code=127 @@ -510,7 +526,8 @@ function assert_string_starts_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -550,7 +567,8 @@ function assert_string_ends_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -571,7 +589,8 @@ function assert_string_not_ends_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}") + local -a actual_arr=() + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -668,7 +687,8 @@ function assert_line_count() { bashunit::assert::should_skip && return 0 local expected="$1" - local -a input_arr=(); [[ $# -gt 1 ]] && input_arr=("${@:2}") + local -a input_arr=() + input_arr=("${@:2}") local input_str input_str=$(printf '%s\n' ${input_arr+"${input_arr[@]}"}) @@ -678,7 +698,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=$(grep -o '\\n' <<<"$input_str" | wc -l | tr -d '[:blank:]') actual=$((actual + additional_new_lines)) fi @@ -689,8 +709,8 @@ function assert_line_count() { label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${input_str}"\ - "to contain number of lines equal to" "${expected}"\ + bashunit::console_results::print_failed_test "${label}" "${input_str}" \ + "to contain number of lines equal to" "${expected}" \ "but found" "${actual}" return fi diff --git a/src/assert_arrays.sh b/src/assert_arrays.sh index 3d8a4c80..05867d6b 100644 --- a/src/assert_arrays.sh +++ b/src/assert_arrays.sh @@ -10,7 +10,8 @@ function assert_array_contains() { label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift - local -a actual=(); [[ $# -gt 0 ]] && actual=("$@") + local -a actual=() + [[ $# -gt 0 ]] && actual=("$@") if ! [[ "${actual[*]:-}" == *"$expected"* ]]; then bashunit::assert::mark_failed @@ -30,7 +31,8 @@ function assert_array_not_contains() { local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift - local -a actual=(); [[ $# -gt 0 ]] && actual=("$@") + local -a actual=() + [[ $# -gt 0 ]] && actual=("$@") if [[ "${actual[*]:-}" == *"$expected"* ]]; then bashunit::assert::mark_failed diff --git a/src/assert_files.sh b/src/assert_files.sh index 3c055832..db521315 100644 --- a/src/assert_files.sh +++ b/src/assert_files.sh @@ -74,7 +74,7 @@ function assert_files_equals() { local expected="$1" local actual="$2" - if [[ "$(diff -u "$expected" "$actual")" != '' ]] ; then + if [[ "$(diff -u "$expected" "$actual")" != '' ]]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label @@ -82,7 +82,7 @@ function assert_files_equals() { bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ - "Diff" "$(diff -u "$expected" "$actual" | sed '1,2d')" + "Diff" "$(diff -u "$expected" "$actual" | sed '1,2d')" return fi @@ -95,7 +95,7 @@ function assert_files_not_equals() { local expected="$1" local actual="$2" - if [[ "$(diff -u "$expected" "$actual")" == '' ]] ; then + if [[ "$(diff -u "$expected" "$actual")" == '' ]]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label @@ -103,7 +103,7 @@ function assert_files_not_equals() { bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ - "Diff" "Files are equals" + "Diff" "Files are equals" return fi diff --git a/src/assert_snapshot.sh b/src/assert_snapshot.sh index c24e783e..a47d14be 100644 --- a/src/assert_snapshot.sh +++ b/src/assert_snapshot.sh @@ -70,7 +70,7 @@ function bashunit::snapshot::initialize() { local path="$1" local content="$2" mkdir -p "$(dirname "$path")" - echo "$content" > "$path" + echo "$content" >"$path" bashunit::state::add_assertions_snapshot } @@ -80,7 +80,7 @@ function bashunit::snapshot::compare() { local func_name="$3" local snapshot - snapshot=$(tr -d '\r' < "$snapshot_path") + snapshot=$(tr -d '\r' <"$snapshot_path") if ! bashunit::snapshot::match_with_placeholder "$actual" "$snapshot"; then local label=$(bashunit::helper::normalize_test_function_name "$func_name") diff --git a/src/benchmark.sh b/src/benchmark.sh index c1d87f04..8b8d6293 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -67,17 +67,18 @@ function bashunit::benchmark::run_function() { local durations_count=0 local i r - for ((i=1; i<=its; i++)); do + for ((i = 1; i <= its; i++)); do local start_time=$(bashunit::clock::now) ( - for ((r=1; r<=revs; r++)); do + for ((r = 1; r <= revs; r++)); do "$fn_name" >/dev/null 2>&1 done ) local end_time=$(bashunit::clock::now) local dur_ns=$(bashunit::math::calculate "($end_time - $start_time)") local dur_ms=$(bashunit::math::calculate "$dur_ns / 1000000") - durations[durations_count]="$dur_ms"; durations_count=$((durations_count + 1)) + durations[durations_count]="$dur_ms" + durations_count=$((durations_count + 1)) if bashunit::env::is_bench_mode_enabled; then local label="$(bashunit::helper::normalize_test_function_name "$fn_name")" @@ -100,7 +101,7 @@ function bashunit::benchmark::print_results() { return fi - if (( ${#_BASHUNIT_BENCH_NAMES[@]} == 0 )); then + if ((${#_BASHUNIT_BENCH_NAMES[@]} == 0)); then return fi diff --git a/src/check_os.sh b/src/check_os.sh index 8ff2d5dd..6b8e126a 100644 --- a/src/check_os.sh +++ b/src/check_os.sh @@ -27,11 +27,11 @@ function bashunit::check_os::init() { } function bashunit::check_os::is_ubuntu() { - command -v apt > /dev/null + command -v apt >/dev/null } function bashunit::check_os::is_alpine() { - command -v apk > /dev/null + command -v apk >/dev/null } function bashunit::check_os::is_nixos() { @@ -49,12 +49,12 @@ function bashunit::check_os::is_macos() { function bashunit::check_os::is_windows() { case "$(uname)" in - *MINGW*|*MSYS*|*CYGWIN*) - return 0 - ;; - *) - return 1 - ;; + *MINGW* | *MSYS* | *CYGWIN*) + return 0 + ;; + *) + return 1 + ;; esac } @@ -62,12 +62,12 @@ function bashunit::check_os::is_busybox() { case "$_BASHUNIT_DISTRO" in - "Alpine") - return 0 - ;; - *) - return 1 - ;; + "Alpine") + return 0 + ;; + *) + return 1 + ;; esac } diff --git a/src/clock.sh b/src/clock.sh index 19ccc789..b43bc735 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -9,34 +9,39 @@ function bashunit::clock::_choose_impl() { local attempts # 1. Try Perl with Time::HiRes - attempts[attempts_count]="Perl"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="Perl" + attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_perl && perl -MTime::HiRes -e "" &>/dev/null; then _BASHUNIT_CLOCK_NOW_IMPL="perl" return 0 fi # 2. Try Python 3 with time module - attempts[attempts_count]="Python"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="Python" + attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_python; then _BASHUNIT_CLOCK_NOW_IMPL="python" return 0 fi # 3. Try Node.js - attempts[attempts_count]="Node"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="Node" + attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_node; then _BASHUNIT_CLOCK_NOW_IMPL="node" return 0 fi # 4. Windows fallback with PowerShell - attempts[attempts_count]="PowerShell"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="PowerShell" + attempts_count=$((attempts_count + 1)) if bashunit::check_os::is_windows && bashunit::dependencies::has_powershell; then _BASHUNIT_CLOCK_NOW_IMPL="powershell" return 0 fi # 5. Unix fallback using `date +%s%N` (if not macOS or Alpine) - attempts[attempts_count]="date"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="date" + attempts_count=$((attempts_count + 1)) if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then local result result=$(date +%s%N 2>/dev/null) @@ -47,14 +52,16 @@ function bashunit::clock::_choose_impl() { fi # 6. Try using native shell EPOCHREALTIME (if available) - attempts[attempts_count]="EPOCHREALTIME"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="EPOCHREALTIME" + attempts_count=$((attempts_count + 1)) if shell_time="$(bashunit::clock::shell_time)"; then _BASHUNIT_CLOCK_NOW_IMPL="shell" return 0 fi # 7. Very last fallback: seconds resolution only - attempts[attempts_count]="date-seconds"; attempts_count=$((attempts_count + 1)) + attempts[attempts_count]="date-seconds" + attempts_count=$((attempts_count + 1)) if date +%s &>/dev/null; then _BASHUNIT_CLOCK_NOW_IMPL="date-seconds" return 0 @@ -72,46 +79,46 @@ function bashunit::clock::now() { fi case "$_BASHUNIT_CLOCK_NOW_IMPL" in - perl) - perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)' - ;; - python) - python - <<'EOF' + perl) + perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)' + ;; + python) + python - <<'EOF' import time, sys sys.stdout.write(str(int(time.time() * 1000000000))) EOF - ;; - node) - node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())' - ;; - powershell) - powershell -Command "\ + ;; + node) + node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())' + ;; + powershell) + powershell -Command "\ \$unixEpoch = [DateTime]'1970-01-01 00:00:00';\ \$now = [DateTime]::UtcNow;\ \$ticksSinceEpoch = (\$now - \$unixEpoch).Ticks;\ \$nanosecondsSinceEpoch = \$ticksSinceEpoch * 100;\ Write-Output \$nanosecondsSinceEpoch\ " - ;; - date) - date +%s%N - ;; - date-seconds) - local seconds - seconds=$(date +%s) - bashunit::math::calculate "$seconds * 1000000000" - ;; - shell) - # shellcheck disable=SC2155 - local shell_time="$(bashunit::clock::shell_time)" - local seconds="${shell_time%%.*}" - local microseconds="${shell_time#*.}" - bashunit::math::calculate "($seconds * 1000000000) + ($microseconds * 1000)" - ;; - *) - bashunit::clock::_choose_impl || return 1 - bashunit::clock::now - ;; + ;; + date) + date +%s%N + ;; + date-seconds) + local seconds + seconds=$(date +%s) + bashunit::math::calculate "$seconds * 1000000000" + ;; + shell) + # shellcheck disable=SC2155 + local shell_time="$(bashunit::clock::shell_time)" + local seconds="${shell_time%%.*}" + local microseconds="${shell_time#*.}" + bashunit::math::calculate "($seconds * 1000000000) + ($microseconds * 1000)" + ;; + *) + bashunit::clock::_choose_impl || return 1 + bashunit::clock::now + ;; esac } diff --git a/src/console_header.sh b/src/console_header.sh index e1be9ecc..4b5c36a0 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -1,64 +1,64 @@ #!/usr/bin/env bash function bashunit::console_header::print_version_with_env() { - local filter=${1:-} - shift || true + local filter=${1:-} + shift || true - if ! bashunit::env::is_show_header_enabled; then - return - fi + if ! bashunit::env::is_show_header_enabled; then + return + fi - bashunit::console_header::print_version "$filter" "$@" + bashunit::console_header::print_version "$filter" "$@" - if bashunit::env::is_dev_mode_enabled; then - printf "%sDev log:%s %s\n" "${_BASHUNIT_COLOR_INCOMPLETE}" "${_BASHUNIT_COLOR_DEFAULT}" "$BASHUNIT_DEV_LOG" - fi + if bashunit::env::is_dev_mode_enabled; then + printf "%sDev log:%s %s\n" "${_BASHUNIT_COLOR_INCOMPLETE}" "${_BASHUNIT_COLOR_DEFAULT}" "$BASHUNIT_DEV_LOG" + fi } function bashunit::console_header::print_version() { - local filter=${1:-} - shift || true - - # Bash 3.0 compatible: check argument count after shift - local files_count=$# - local total_tests - if [[ "$files_count" -eq 0 ]]; then - total_tests=0 - elif bashunit::parallel::is_enabled && bashunit::env::is_simple_output_enabled; then - # Skip counting in parallel+simple mode for faster startup - total_tests=0 - else - total_tests=$(bashunit::helper::find_total_tests "$filter" "$@") - fi - - if bashunit::env::is_header_ascii_art_enabled; then - cat < [arguments] [options] Commands: @@ -89,7 +89,7 @@ EOF } function bashunit::console_header::print_test_help() { - cat < [args...] bashunit assert "" [ ...] diff --git a/src/console_results.sh b/src/console_results.sh index 9d3242ff..b8debe26 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -62,7 +62,7 @@ function bashunit::console_results::render_result() { printf "%sAssertions:%s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" if [[ "$tests_passed" -gt 0 ]] || [[ "$assertions_passed" -gt 0 ]]; then - printf " %s%s passed%s," "$_BASHUNIT_COLOR_PASSED" "$assertions_passed" "$_BASHUNIT_COLOR_DEFAULT" + printf " %s%s passed%s," "$_BASHUNIT_COLOR_PASSED" "$assertions_passed" "$_BASHUNIT_COLOR_DEFAULT" fi if [[ "$tests_skipped" -gt 0 ]] || [[ "$assertions_skipped" -gt 0 ]]; then printf " %s%s skipped%s," "$_BASHUNIT_COLOR_SKIPPED" "$assertions_skipped" "$_BASHUNIT_COLOR_DEFAULT" @@ -126,11 +126,11 @@ function bashunit::console_results::print_execution_time() { return fi - local time_in_seconds=$(( time / 1000 )) + local time_in_seconds=$((time / 1000)) if [[ "$time_in_seconds" -ge 60 ]]; then - local minutes=$(( time_in_seconds / 60 )) - local seconds=$(( time_in_seconds % 60 )) + local minutes=$((time_in_seconds / 60)) + local seconds=$((time_in_seconds % 60)) printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ "Time taken: ${minutes}m ${seconds}s" return @@ -147,9 +147,9 @@ function bashunit::console_results::format_duration() { local duration_ms="$1" if [[ "$duration_ms" -ge 60000 ]]; then - local time_in_seconds=$(( duration_ms / 1000 )) - local minutes=$(( time_in_seconds / 60 )) - local seconds=$(( time_in_seconds % 60 )) + local time_in_seconds=$((duration_ms / 1000)) + local minutes=$((time_in_seconds / 60)) + local seconds=$((time_in_seconds % 60)) echo "${minutes}m ${seconds}s" elif [[ "$duration_ms" -ge 1000 ]]; then local formatted_seconds @@ -217,9 +217,9 @@ function bashunit::console_results::print_successful_test() { if bashunit::env::is_show_execution_time_enabled; then local time_display if [[ "$duration" -ge 60000 ]]; then - local time_in_seconds=$(( duration / 1000 )) - local minutes=$(( time_in_seconds / 60 )) - local seconds=$(( time_in_seconds % 60 )) + local time_in_seconds=$((duration / 1000)) + local minutes=$((time_in_seconds / 60)) + local seconds=$((time_in_seconds % 60)) time_display="${minutes}m ${seconds}s" elif [[ "$duration" -ge 1000 ]]; then local formatted_seconds @@ -241,7 +241,7 @@ function bashunit::console_results::print_failure_message() { local line line="$(printf "\ ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s - ${_BASHUNIT_COLOR_FAINT}Message:${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n"\ + ${_BASHUNIT_COLOR_FAINT}Message:${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ "${test_name}" "${failure_message}")" bashunit::state::print_line "failure" "$line" @@ -266,13 +266,12 @@ ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s line="$line$(printf "\ ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ - "${extra_key}" "${extra_value}")" + "${extra_key}" "${extra_value}")" fi bashunit::state::print_line "failed" "$line" } - function bashunit::console_results::print_failed_snapshot_test() { local function_name=$1 local snapshot_file=$2 @@ -284,12 +283,12 @@ function bashunit::console_results::print_failed_snapshot_test() { if bashunit::dependencies::has_git; then local actual_file="${snapshot_file}.tmp" - echo "$actual_content" > "$actual_file" + echo "$actual_content" >"$actual_file" local git_diff_output git_diff_output="$(git diff --no-index --word-diff --color=always \ - "$snapshot_file" "$actual_file" 2>/dev/null \ - | tail -n +6 | sed "s/^/ /")" + "$snapshot_file" "$actual_file" 2>/dev/null | + tail -n +6 | sed "s/^/ /")" line="$line$git_diff_output" rm "$actual_file" @@ -354,7 +353,7 @@ function bashunit::console_results::print_error_test() { local output_line while IFS= read -r output_line; do line="$line$(printf " %s\n" "$output_line")" - done <<< "$raw_output" + done <<<"$raw_output" fi bashunit::state::print_line "error" "$line" @@ -397,7 +396,7 @@ function bashunit::console_results::print_skipped_tests_and_reset() { echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_skipped skipped tests:${_BASHUNIT_COLOR_DEFAULT}\n" fi - tr -d '\r' < "$SKIPPED_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' + tr -d '\r' <"$SKIPPED_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' rm "$SKIPPED_OUTPUT_PATH" echo "" @@ -419,7 +418,7 @@ function bashunit::console_results::print_incomplete_tests_and_reset() { echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_incomplete incomplete tests:${_BASHUNIT_COLOR_DEFAULT}\n" fi - tr -d '\r' < "$INCOMPLETE_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' + tr -d '\r' <"$INCOMPLETE_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' rm "$INCOMPLETE_OUTPUT_PATH" echo "" diff --git a/src/coverage.sh b/src/coverage.sh index f62f0906..30f4ae6f 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -31,7 +31,7 @@ function bashunit::coverage::auto_discover_paths() { # Remove test suffixes to get source name: assert_test.sh -> assert local source_name="${file_basename%_test.sh}" [[ "$source_name" == "$file_basename" ]] && source_name="${file_basename%Test.sh}" - [[ "$source_name" == "$file_basename" ]] && continue # Not a test file pattern + [[ "$source_name" == "$file_basename" ]] && continue # Not a test file pattern # Find matching source files recursively local found_file @@ -41,7 +41,8 @@ function bashunit::coverage::auto_discover_paths() { [[ "$found_file" == *Test* ]] && continue [[ "$found_file" == *vendor* ]] && continue [[ "$found_file" == *node_modules* ]] && continue - discovered_paths[discovered_paths_count]="$found_file"; discovered_paths_count=$((discovered_paths_count + 1)) + discovered_paths[discovered_paths_count]="$found_file" + discovered_paths_count=$((discovered_paths_count + 1)) done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null) done @@ -76,10 +77,10 @@ function bashunit::coverage::init() { _BASHUNIT_COVERAGE_TEST_HITS_FILE="${coverage_dir}/test_hits.dat" # Initialize empty files - : > "$_BASHUNIT_COVERAGE_DATA_FILE" - : > "$_BASHUNIT_COVERAGE_TRACKED_FILES" - : > "$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" - : > "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + : >"$_BASHUNIT_COVERAGE_DATA_FILE" + : >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + : >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + : >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" export _BASHUNIT_COVERAGE_DATA_FILE export _BASHUNIT_COVERAGE_TRACKED_FILES @@ -187,12 +188,12 @@ function bashunit::coverage::record_line() { # Record the hit (only if parent directory exists) if [[ -d "$(dirname "$data_file")" ]]; then - echo "${normalized_file}:${lineno}" >> "$data_file" + echo "${normalized_file}:${lineno}" >>"$data_file" # Also record which test caused this hit (if we're in a test context) if [[ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" && -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]]; then # Format: source_file:line|test_file:test_function - echo "${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" >> "$test_hits_file" + echo "${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" >>"$test_hits_file" fi fi } @@ -213,7 +214,7 @@ function bashunit::coverage::should_track() { if bashunit::parallel::is_enabled && [[ -n "$cache_file" ]]; then cache_file="${cache_file}.$$" # Initialize per-process cache if needed - [[ ! -f "$cache_file" ]] && [[ -d "$(dirname "$cache_file")" ]] && : > "$cache_file" + [[ ! -f "$cache_file" ]] && [[ -d "$(dirname "$cache_file")" ]] && : >"$cache_file" fi if [[ -n "$cache_file" && -f "$cache_file" ]]; then local cached_decision @@ -236,12 +237,12 @@ function bashunit::coverage::should_track() { for pattern in $BASHUNIT_COVERAGE_EXCLUDE; do # shellcheck disable=SC2254 case "$normalized_file" in - *$pattern*) - IFS="$old_ifs" - # Cache exclusion decision (use per-process cache in parallel mode) - [[ -n "$cache_file" && -f "$cache_file" ]] && echo "${file}:0" >> "$cache_file" - return 1 - ;; + *$pattern*) + IFS="$old_ifs" + # Cache exclusion decision (use per-process cache in parallel mode) + [[ -n "$cache_file" && -f "$cache_file" ]] && echo "${file}:0" >>"$cache_file" + return 1 + ;; esac done @@ -266,12 +267,12 @@ function bashunit::coverage::should_track() { if [[ "$matched" == "false" ]]; then # Cache exclusion decision (use per-process cache in parallel mode) - [[ -n "$cache_file" && -f "$cache_file" ]] && echo "${file}:0" >> "$cache_file" + [[ -n "$cache_file" && -f "$cache_file" ]] && echo "${file}:0" >>"$cache_file" return 1 fi # Cache tracking decision (use per-process cache in parallel mode) - [[ -n "$cache_file" && -f "$cache_file" ]] && echo "${file}:1" >> "$cache_file" + [[ -n "$cache_file" && -f "$cache_file" ]] && echo "${file}:1" >>"$cache_file" # Track this file for later reporting # In parallel mode, use a per-process file to avoid race conditions @@ -284,7 +285,7 @@ function bashunit::coverage::should_track() { if [[ -d "$(dirname "$tracked_file")" ]]; then # Check if not already written to avoid duplicates if ! grep -q "^${normalized_file}$" "$tracked_file" 2>/dev/null; then - echo "$normalized_file" >> "$tracked_file" + echo "$normalized_file" >>"$tracked_file" fi fi @@ -304,9 +305,9 @@ function bashunit::coverage::aggregate_parallel() { if [[ -n "$pid_files" ]]; then while IFS= read -r pid_file; do [[ -f "$pid_file" ]] || continue - cat "$pid_file" >> "$base_file" + cat "$pid_file" >>"$base_file" rm -f "$pid_file" - done <<< "$pid_files" + done <<<"$pid_files" fi # Find and merge all per-process tracked files lists @@ -314,9 +315,9 @@ function bashunit::coverage::aggregate_parallel() { if [[ -n "$pid_files" ]]; then while IFS= read -r pid_file; do [[ -f "$pid_file" ]] || continue - cat "$pid_file" >> "$tracked_base" + cat "$pid_file" >>"$tracked_base" rm -f "$pid_file" - done <<< "$pid_files" + done <<<"$pid_files" fi # Find and merge all per-process test hits files @@ -325,9 +326,9 @@ function bashunit::coverage::aggregate_parallel() { if [[ -n "$pid_files" ]]; then while IFS= read -r pid_file; do [[ -f "$pid_file" ]] || continue - cat "$pid_file" >> "$test_hits_base" + cat "$pid_file" >>"$test_hits_base" rm -f "$pid_file" - done <<< "$pid_files" + done <<<"$pid_files" fi fi @@ -354,7 +355,7 @@ function bashunit::coverage::is_executable_line() { : "$lineno" # Skip empty lines (line with only whitespace) - [[ -z "${line// }" ]] && return 1 + [[ -z "${line// /}" ]] && return 1 # Skip comment-only lines (including shebang) [[ "$line" =~ ^[[:space:]]*# ]] && return 1 @@ -390,7 +391,7 @@ function bashunit::coverage::get_executable_lines() { while IFS= read -r line || [[ -n "$line" ]]; do ((lineno++)) bashunit::coverage::is_executable_line "$line" "$lineno" && ((count++)) - done < "$file" + done <"$file" echo "$count" } @@ -405,7 +406,7 @@ function bashunit::coverage::get_hit_lines() { # Get unique hit line numbers local hit_lines - hit_lines=$( (grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null || true) | \ + hit_lines=$( (grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null || true) | cut -d: -f2 | sort -u) if [[ -z "$hit_lines" ]]; then @@ -437,7 +438,7 @@ function bashunit::coverage::get_line_hits() { return fi - local count=0 + local count count=$(grep -c "^${file}:${lineno}$" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null) || count=0 echo "$count" } @@ -453,8 +454,8 @@ function bashunit::coverage::get_all_line_hits() { # Extract all lines for this file, count occurrences of each line number local count lineno - grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | \ - cut -d: -f2 | sort | uniq -c | \ + grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | + cut -d: -f2 | sort | uniq -c | while read -r count lineno; do echo "${lineno}:${count}" done @@ -471,7 +472,7 @@ function bashunit::coverage::get_all_line_tests() { # Format in file: source_file:line|test_file:test_function # Output: lineno|test_file:test_function - grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | \ + grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | sed "s|^${file}:||" | sort -u } @@ -541,7 +542,7 @@ function bashunit::coverage::extract_functions() { brace_count=0 fi fi - done < "$file" + done <"$file" # Handle unclosed function (shouldn't happen in valid code) if [[ $in_function -eq 1 && -n "$current_fn" ]]; then @@ -635,9 +636,9 @@ function bashunit::coverage::report_text() { if [[ "${BASHUNIT_NO_COLOR:-false}" != "true" ]]; then reset=$'\033[0m' case "$class" in - high) color=$'\033[32m' ;; # Green - medium) color=$'\033[33m' ;; # Yellow - low) color=$'\033[31m' ;; # Red + high) color=$'\033[32m' ;; # Green + medium) color=$'\033[33m' ;; # Yellow + low) color=$'\033[31m' ;; # Red esac fi @@ -664,9 +665,9 @@ function bashunit::coverage::report_text() { if [[ "${BASHUNIT_NO_COLOR:-false}" != "true" ]]; then reset=$'\033[0m' case "$total_class" in - high) color=$'\033[32m' ;; - medium) color=$'\033[33m' ;; - low) color=$'\033[31m' ;; + high) color=$'\033[32m' ;; + medium) color=$'\033[33m' ;; + low) color=$'\033[31m' ;; esac fi @@ -706,7 +707,7 @@ function bashunit::coverage::report_lcov() { ((lineno++)) bashunit::coverage::is_executable_line "$line" "$lineno" || continue echo "DA:${lineno},$(bashunit::coverage::get_line_hits "$file" "$lineno")" - done < "$file" + done <"$file" local executable hit executable=$(bashunit::coverage::get_executable_lines "$file") @@ -716,7 +717,7 @@ function bashunit::coverage::report_lcov() { echo "LH:$hit" echo "end_of_record" done < <(bashunit::coverage::get_tracked_files) - } > "$output_file" + } >"$output_file" } function bashunit::coverage::check_threshold() { @@ -791,7 +792,8 @@ function bashunit::coverage::report_html() { local safe_filename safe_filename=$(bashunit::coverage::path_to_filename "$file") - file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename"; file_data_count=$((file_data_count + 1)) + file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename" + file_data_count=$((file_data_count + 1)) # Generate individual file HTML bashunit::coverage::generate_file_html "$file" "$output_dir/files/${safe_filename}.html" @@ -816,6 +818,8 @@ function bashunit::coverage::report_html() { } function bashunit::coverage::generate_index_html() { + # Set normal IFS for array operations throughout the function (Bash 3.0/4.3 compatible) + local IFS=$' \t\n' local output_file="$1" local total_hit="$2" local total_executable="$3" @@ -825,7 +829,6 @@ function bashunit::coverage::generate_index_html() { local tests_failed="$7" shift 7 # Handle array passed as arguments - Bash 3.0 compatible - local IFS=$' \t\n' local -a file_data=() local file_count=0 if [[ $# -gt 0 ]]; then @@ -843,19 +846,25 @@ function bashunit::coverage::generate_index_html() { local total_class gauge_color_start gauge_color_end gauge_text_gradient total_class=$(bashunit::coverage::get_coverage_class "$total_pct") case "$total_class" in - high) - gauge_color_start="#10b981"; gauge_color_end="#34d399" - gauge_text_gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)" ;; - medium) - gauge_color_start="#f59e0b"; gauge_color_end="#fbbf24" - gauge_text_gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)" ;; - low) - gauge_color_start="#ef4444"; gauge_color_end="#f87171" - gauge_text_gradient="linear-gradient(135deg, #ef4444 0%, #f87171 100%)" ;; + high) + gauge_color_start="#10b981" + gauge_color_end="#34d399" + gauge_text_gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)" + ;; + medium) + gauge_color_start="#f59e0b" + gauge_color_end="#fbbf24" + gauge_text_gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)" + ;; + low) + gauge_color_start="#ef4444" + gauge_color_end="#f87171" + gauge_text_gradient="linear-gradient(135deg, #ef4444 0%, #f87171 100%)" + ;; esac { - cat << 'EOF' + cat <<'EOF' @@ -894,7 +903,7 @@ function bashunit::coverage::generate_index_html() { .gauge-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; width: 100%; } EOF echo " .gauge-percent { font-size: 3.5rem; font-weight: 800; background: ${gauge_text_gradient}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1; margin: 0; display: block; }" - cat << 'EOF' + cat <<'EOF' .gauge-label { color: var(--text-secondary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 2px; margin: 0; display: block; } .gauge-info { flex: 1; } .gauge-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 12px; } @@ -984,7 +993,7 @@ EOF
    EOF echo "
    v${BASHUNIT_VERSION:-0.0.0}
    " - cat << 'EOF' + cat <<'EOF'

    Code Coverage Report

    Comprehensive line-by-line coverage analysis for your bash scripts

    @@ -999,18 +1008,18 @@ EOF EOF echo " " echo " " - cat << 'EOF' + cat <<'EOF' EOF echo " " - cat << 'EOF' + cat <<'EOF'
    EOF echo "
    ${total_pct}%
    " - cat << 'EOF' + cat <<'EOF'
    Coverage
    @@ -1018,7 +1027,7 @@ EOF

    Overall Code Coverage

    EOF echo "

    ${total_hit} of ${total_executable} executable lines covered across ${file_count} files.

    " - cat << 'EOF' + cat <<'EOF'
    @@ -1029,21 +1038,21 @@ EOF Total: EOF echo " ${total_executable} lines" - cat << 'EOF' + cat <<'EOF'
    Covered: EOF echo " ${total_hit} lines" - cat << 'EOF' + cat <<'EOF'
    Uncovered: EOF echo " ${total_uncovered} lines" - cat << 'EOF' + cat <<'EOF'
    @@ -1055,28 +1064,28 @@ EOF Files: EOF echo " ${file_count}" - cat << 'EOF' + cat <<'EOF'
    Tests: EOF echo " ${tests_total} total" - cat << 'EOF' + cat <<'EOF'
    Passed: EOF echo " ${tests_passed}" - cat << 'EOF' + cat <<'EOF'
    Failed: EOF echo " ${tests_failed}" - cat << 'EOF' + cat <<'EOF'
    @@ -1091,19 +1100,19 @@ EOF EOF echo " ≥${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High" - cat << 'EOF' + cat <<'EOF'
    EOF echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium" - cat << 'EOF' + cat <<'EOF'
    EOF echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low" - cat << 'EOF' + cat <<'EOF'
    @@ -1121,7 +1130,7 @@ EOF local data display_file hit executable pct safe_filename for data in ${file_data[@]+"${file_data[@]}"}; do - IFS='|' read -r display_file hit executable pct safe_filename <<< "$data" + IFS='|' read -r display_file hit executable pct safe_filename <<<"$data" local class class=$(bashunit::coverage::get_coverage_class "$pct") @@ -1150,7 +1159,7 @@ EOF echo " " done - cat << 'EOF' + cat <<'EOF' @@ -1168,7 +1177,7 @@ EOF EOF - } > "$output_file" + } >"$output_file" } function bashunit::coverage::generate_file_html() { @@ -1212,11 +1221,11 @@ function bashunit::coverage::generate_file_html() { # Count total lines and functions local total_lines - total_lines=$(wc -l < "$file" | tr -d ' ') + total_lines=$(wc -l <"$file" | tr -d ' ') local non_executable=$((total_lines - executable)) { - cat << 'EOF' + cat <<'EOF' @@ -1224,7 +1233,7 @@ function bashunit::coverage::generate_file_html() { EOF echo " $(basename "$display_file") | Coverage Report" - cat << 'EOF' + cat <<'EOF'