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/workflows/tests-bash-3.0.yml b/.github/workflows/tests-bash-3.0.yml new file mode 100644 index 00000000..bb2eb250 --- /dev/null +++ b/.github/workflows/tests-bash-3.0.yml @@ -0,0 +1,101 @@ +name: Bash 3.0 Compatibility + +on: + pull_request: + push: + branches: + - main + +jobs: + build-image: + name: "Build Bash 3.0 Image" + 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 \ + git \ + procps \ + && 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: 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 + + - name: Run tests with Bash 3.0 (${{ matrix.name }}) + run: | + docker run --rm \ + -v "$(pwd)":/bashunit \ + -w /bashunit \ + bashunit-bash3 \ + /opt/bash-3.0/bin/bash ./bashunit ${{ matrix.flags }} tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bd40185e..24bdd42a 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 - Add Claude Code configuration with custom skills, agents, and rules - Custom skills for TDD workflow, test fixes, assertions, coverage, and releases 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..a7582db5 100755 --- a/bashunit +++ b/bashunit @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -declare -r BASHUNIT_MIN_BASH_VERSION="3.2" +declare -r BASHUNIT_MIN_BASH_VERSION="3.0" function _check_bash_version() { local current_version @@ -16,10 +16,10 @@ function _check_bash_version() { current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" fi - local major minor - IFS=. read -r major minor _ <<< "$current_version" + local major + IFS=. read -r major _ <<<"$current_version" - if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then + if ((major < 3)); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 exit 1 fi @@ -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..6963c94a 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 @@ -76,7 +76,8 @@ function build::process_file() { sourced_file=$(eval echo "$sourced_file") # Handle relative paths if necessary - if [[ ! "$sourced_file" =~ ^/ ]]; then + local _absolute_path_pattern='^/' + if [[ ! "$sourced_file" =~ $_absolute_path_pattern ]]; then sourced_file="$(dirname "$file")/$sourced_file" fi @@ -139,7 +140,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 +160,7 @@ function build::generate_checksum() { checksum=$(sha256sum "$out") fi - echo "$checksum" > "$(dirname "$out")/checksum" + echo "$checksum" >"$(dirname "$out")/checksum" echo "$checksum" } @@ -173,15 +174,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/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/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 7749ef0c..80411e34 100755 --- a/install.sh +++ b/install.sh @@ -2,8 +2,13 @@ # 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 + command -v git >/dev/null 2>&1 } function build_and_install_beta() { @@ -39,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." @@ -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..32a33e0e 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' @@ -391,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 } @@ -453,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 @@ -504,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 @@ -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 @@ -552,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 @@ -652,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 @@ -752,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 @@ -866,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 @@ -1001,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 690e4bd5..a39ab75b 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 @@ -71,12 +83,16 @@ function assert_false() { function bashunit::run_command_or_eval() { local cmd="$1" - if [[ "$cmd" =~ ^eval ]]; then - eval "${cmd#eval }" &> /dev/null - elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then - eval "$cmd" &> /dev/null + local _re='^eval' + if [[ "$cmd" =~ $_re ]]; then + eval "${cmd#eval }" &>/dev/null else - "$cmd" &> /dev/null + _re='^alias' + if [[ "$(command -v "$cmd")" =~ $_re ]]; then + eval "$cmd" &>/dev/null + else + "$cmd" &>/dev/null + fi fi return $? } @@ -219,7 +235,8 @@ function assert_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -242,20 +259,23 @@ function assert_contains_ignore_case() { local expected="$1" local actual="$2" - shopt -s nocasematch + # 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 =~ $expected ]]; then + 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 } @@ -263,7 +283,8 @@ function assert_not_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -284,11 +305,12 @@ function assert_matches() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") - if ! [[ $actual =~ $expected ]]; then + if ! [[ "$actual" =~ $expected ]]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label @@ -305,11 +327,12 @@ function assert_not_matches() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") - if [[ $actual =~ $expected ]]; then + if [[ "$actual" =~ $expected ]]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label @@ -336,23 +359,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 @@ -379,16 +402,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 @@ -408,7 +431,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" @@ -427,7 +450,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 @@ -447,7 +470,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 @@ -464,7 +487,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 @@ -484,7 +507,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 @@ -507,7 +530,8 @@ function assert_string_starts_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -547,7 +571,8 @@ function assert_string_ends_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -568,7 +593,8 @@ function assert_string_not_ends_with() { bashunit::assert::should_skip && return 0 local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -663,11 +689,13 @@ function assert_greater_or_equal_than() { function assert_line_count() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' local expected="$1" - local input_arr=("${@:2}") + local -a input_arr + 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 @@ -675,8 +703,8 @@ 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:]') - ((actual+=additional_new_lines)) + additional_new_lines=$(grep -o '\\n' <<<"$input_str" | wc -l | tr -d '[:blank:]') + actual=$((actual + additional_new_lines)) fi if [[ "$expected" != "$actual" ]]; then @@ -686,8 +714,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 214db95a..1d116997 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=("${@}") + local -a actual + 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=("$@") + local -a actual + 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/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 1abd4fcc..92a509bd 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -16,22 +16,34 @@ 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 - revs="${BASH_REMATCH[1]}" - elif [[ $annotation =~ @revolutions=([0-9]+) ]]; then + local _re='@revs=([0-9]+)' + if [[ "$annotation" =~ $_re ]]; then revs="${BASH_REMATCH[1]}" + else + _re='@revolutions=([0-9]+)' + if [[ "$annotation" =~ $_re ]]; then + revs="${BASH_REMATCH[1]}" + fi fi - if [[ $annotation =~ @its=([0-9]+) ]]; then - its="${BASH_REMATCH[1]}" - elif [[ $annotation =~ @iterations=([0-9]+) ]]; then + _re='@its=([0-9]+)' + if [[ "$annotation" =~ $_re ]]; then its="${BASH_REMATCH[1]}" + else + _re='@iterations=([0-9]+)' + if [[ "$annotation" =~ $_re ]]; then + its="${BASH_REMATCH[1]}" + fi fi - if [[ $annotation =~ @max_ms=([0-9.]+) ]]; then - max_ms="${BASH_REMATCH[1]}" - elif [[ $annotation =~ @max_ms=([0-9.]+) ]]; then + _re='@max_ms=([0-9.]+)' + if [[ "$annotation" =~ $_re ]]; then max_ms="${BASH_REMATCH[1]}" + else + _re='@max_ms=([0-9.]+)' + if [[ "$annotation" =~ $_re ]]; then + max_ms="${BASH_REMATCH[1]}" + fi fi if [[ -n "$max_ms" ]]; then @@ -42,11 +54,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,19 +67,23 @@ function bashunit::benchmark::run_function() { local revs=$2 local its=$3 local max_ms=$4 - local durations=() + local IFS=$' \t\n' + local -a durations=() + 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+=("$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")" @@ -77,7 +93,8 @@ function bashunit::benchmark::run_function() { done local sum=0 - for d in "${durations[@]}"; do + local d + for d in "${durations[@]+"${durations[@]}"}"; do sum=$(bashunit::math::calculate "$sum + $d") done local avg=$(bashunit::math::calculate "$sum / ${#durations[@]}") @@ -89,7 +106,7 @@ function bashunit::benchmark::print_results() { return fi - if (( ${#_BASHUNIT_BENCH_NAMES[@]} == 0 )); then + if ((${#_BASHUNIT_BENCH_NAMES[@]} == 0)); then return fi @@ -101,8 +118,10 @@ function bashunit::benchmark::print_results() { bashunit::print_line 80 "=" printf "\n" + local IFS=$' \t\n' local has_threshold=false - for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]}"; do + local val + for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]+"${_BASHUNIT_BENCH_MAX_MILLIS[@]}"}"; do if [[ -n "$val" ]]; then has_threshold=true break @@ -115,12 +134,13 @@ 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]}" - 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" @@ -129,13 +149,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/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 2ba3509e..d7978559 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -4,55 +4,65 @@ _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) - if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then + local _re='^[0-9]+$' + if [[ "$result" != *N ]] && [[ "$result" =~ $_re ]]; then _BASHUNIT_CLOCK_NOW_IMPL="date" return 0 fi 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 @@ -70,46 +80,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/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_header.sh b/src/console_header.sh index cc486935..2258b427 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -2,39 +2,39 @@ function bashunit::console_header::print_version_with_env() { local filter=${1:-} - local files=("${@:2}") + shift || true if ! bashunit::env::is_show_header_enabled; then return fi - bashunit::console_header::print_version "$filter" "${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" + 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:-} - if [[ -n "$filter" ]]; then - shift - fi + shift || true - local files=("$@") + # Bash 3.0 compatible: check argument count after shift + local files_count=$# 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 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 cat < [arguments] [options] Commands: @@ -90,16 +90,16 @@ EOF } function bashunit::console_header::print_test_help() { - cat < Run a standalone assert function (deprecated: use 'bashunit assert') @@ -147,7 +147,7 @@ EOF } function bashunit::console_header::print_bench_help() { - cat < [args...] - bashunit assert "" [ ...] + bashunit assert "" [ ...] Run standalone assertion(s) without creating a test file. @@ -264,7 +264,7 @@ Arguments: arg Expected value for the assertion Note: You can also use 'bashunit test --assert ' (deprecated). - The 'bashunit assert' subcommand is the recommended approach. + The 'bashunit assert' subcommand is the recommended approach. More info: https://bashunit.typeddevs.com/standalone EOF diff --git a/src/console_results.sh b/src/console_results.sh index 143ccbfd..ab96cd26 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 @@ -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 @@ -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'" @@ -216,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 @@ -240,7 +241,8 @@ 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" @@ -262,16 +264,15 @@ ${_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}")" + "${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 @@ -283,14 +284,14 @@ 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+="$git_diff_output" + line="$line$git_diff_output" rm "$actual_file" fi @@ -305,7 +306,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 +320,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,10 +350,11 @@ 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}")" + local output_line while IFS= read -r output_line; do - line+="$(printf " %s\n" "$output_line")" - done <<< "$raw_output" + line="$line$(printf " %s\n" "$output_line")" + done <<<"$raw_output" fi bashunit::state::print_line "error" "$line" @@ -395,7 +397,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 "" @@ -417,7 +419,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 34f96cd8..ddab9226 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:-}" @@ -20,6 +20,8 @@ function bashunit::coverage::auto_discover_paths() { local project_root 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 @@ -29,21 +31,23 @@ 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 while IFS= read -r -d '' found_file; do # Skip test files and vendor directories [[ "$found_file" == *test* ]] && continue [[ "$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 } @@ -73,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 @@ -184,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 } @@ -203,14 +207,14 @@ 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" 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 @@ -233,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 @@ -263,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 @@ -281,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 @@ -296,14 +300,14 @@ 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 [[ -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 @@ -311,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 @@ -322,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 @@ -338,7 +342,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 @@ -351,25 +355,30 @@ 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 + local _re='^[[:space:]]*#' + [[ "$line" =~ $_re ]] && return 1 # Skip function declaration lines (but not single-line functions with body) [[ "$line" =~ $_BASHUNIT_COVERAGE_FUNC_PATTERN ]] && return 1 # Skip lines with only braces - [[ "$line" =~ ^[[:space:]]*[\{\}][[:space:]]*$ ]] && return 1 + _re='^[[:space:]]*[\{\}][[:space:]]*$' + [[ "$line" =~ $_re ]] && 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 + _re='^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$' + [[ "$line" =~ $_re ]] && return 1 # Skip case patterns like "--option)" or "*)" - [[ "$line" =~ ^[[:space:]]*[^\)]+\)[[:space:]]*$ ]] && return 1 + _re='^[[:space:]]*[^\)]+\)[[:space:]]*$' + [[ "$line" =~ $_re ]] && return 1 # Skip standalone ) for arrays/subshells - [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]] && return 1 + _re='^[[:space:]]*\)[[:space:]]*(#.*)?$' + [[ "$line" =~ $_re ]] && return 1 return 0 } @@ -378,11 +387,12 @@ 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++)) bashunit::coverage::is_executable_line "$line" "$lineno" && ((count++)) - done < "$file" + done <"$file" echo "$count" } @@ -397,7 +407,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 @@ -444,8 +454,9 @@ function bashunit::coverage::get_all_line_hits() { fi # Extract all lines for this file, count occurrences of each line number - grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | \ - cut -d: -f2 | sort | uniq -c | \ + local count lineno + 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 @@ -462,7 +473,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 } @@ -476,6 +487,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++)) @@ -487,10 +499,14 @@ 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 - fn_name="${BASH_REMATCH[2]}" - elif [[ "$line" =~ ^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$ ]]; then + local _re='^[[:space:]]*(function[[:space:]]+)?([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*(#.*)?$' + if [[ "$line" =~ $_re ]]; then fn_name="${BASH_REMATCH[2]}" + else + _re='^[[:space:]]*(function[[:space:]]+)([a-zA-Z_][a-zA-Z0-9_:]*)[[:space:]]*\{[[:space:]]*(#.*)?$' + if [[ "$line" =~ $_re ]]; then + fn_name="${BASH_REMATCH[2]}" + fi fi if [[ -n "$fn_name" ]]; then @@ -505,7 +521,8 @@ function bashunit::coverage::extract_functions() { brace_count=$((brace_count + ${#open_braces} - ${#close_braces})) # Single-line function - if [[ $brace_count -eq 0 && "$line" =~ \{ && "$line" =~ \} ]]; then + local _re_ob='\{' _re_cb='\}' + if [[ $brace_count -eq 0 ]] && [[ "$line" =~ $_re_ob ]] && [[ "$line" =~ $_re_cb ]]; then echo "${current_fn}:${fn_start}:${lineno}" in_function=0 current_fn="" @@ -528,7 +545,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 @@ -549,7 +566,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 @@ -583,8 +600,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" @@ -603,6 +620,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 @@ -613,17 +631,17 @@ 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="" 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 @@ -650,9 +668,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 @@ -686,12 +704,13 @@ 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++)) 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") @@ -701,7 +720,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() { @@ -754,9 +773,12 @@ 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 file_data=() + local -a file_data=() + local file_data_count=0 + local file="" while IFS= read -r file; do [[ -z "$file" || ! -f "$file" ]] && continue @@ -766,14 +788,15 @@ 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" @@ -798,6 +821,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" @@ -806,13 +831,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 -a 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))) @@ -821,19 +849,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' @@ -872,7 +906,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; } @@ -962,7 +996,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

@@ -977,18 +1011,18 @@ EOF EOF echo " " echo " " - cat << 'EOF' + cat <<'EOF' EOF echo " " - cat << 'EOF' + cat <<'EOF'
EOF echo "
${total_pct}%
" - cat << 'EOF' + cat <<'EOF'
Coverage
@@ -996,7 +1030,7 @@ EOF

Overall Code Coverage

EOF echo "

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

" - cat << 'EOF' + cat <<'EOF'
@@ -1007,21 +1041,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'
@@ -1033,28 +1067,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'
@@ -1069,19 +1103,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'
@@ -1097,8 +1131,9 @@ 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" + IFS='|' read -r display_file hit executable pct safe_filename <<<"$data" local class class=$(bashunit::coverage::get_coverage_class "$pct") @@ -1127,7 +1162,7 @@ EOF echo " " done - cat << 'EOF' + cat <<'EOF' @@ -1145,7 +1180,7 @@ EOF EOF - } > "$output_file" + } >"$output_file" } function bashunit::coverage::generate_file_html() { @@ -1169,7 +1204,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.2 compatibility (no associative arrays) + # Using indexed array for Bash 3.0 compatibility (no associative arrays) local -a tests_by_line=() local _line_and_test while IFS= read -r _line_and_test; do @@ -1189,11 +1224,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' @@ -1201,7 +1236,7 @@ function bashunit::coverage::generate_file_html() { EOF echo " $(basename "$display_file") | Coverage Report" - cat << 'EOF' + cat <<'EOF'