diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d98e2153..538d7a4e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -**bashunit** is a comprehensive, lightweight Bash testing framework (requires Bash 3.2+) focused on developer experience. It provides assertions, test doubles (spies/mocks), data providers, snapshots, and more. +**bashunit** is a comprehensive, lightweight Bash testing framework (requires Bash 3.0+) focused on developer experience. It provides assertions, test doubles (spies/mocks), data providers, snapshots, and more. **Documentation:** https://bashunit.typeddevs.com @@ -11,7 +11,7 @@ This directory (`.claude/`) contains comprehensive Claude Code configuration: - **Custom skills**: `/tdd-cycle`, `/fix-test`, `/add-assertion`, `/check-coverage`, `/pre-release` - **Custom commands**: `/gh-issue` (complete GitHub issue → PR workflow) -- **Modular rules**: Bash 3.2+ compatibility, testing patterns, TDD workflow +- **Modular rules**: Bash 3.0+ compatibility, testing patterns, TDD workflow - **Automation**: Agent SDK examples for CI/CD See `README.md` in this directory for complete documentation. @@ -27,7 +27,7 @@ See `README.md` in this directory for complete documentation. Every change starts from a failing test. No exceptions. -### Bash 3.2+ Compatible +### Bash 3.0+ Compatible Works on macOS default bash. **Prohibited features:** - ❌ `declare -A` (associative arrays - Bash 4.0+) @@ -52,7 +52,7 @@ shfmt -w . # Code formatting ``` bashunit/ -├── src/ # Core framework code (Bash 3.2+ compatible) +├── src/ # Core framework code (Bash 3.0+ compatible) │ ├── bashunit.sh # Main entry point │ ├── assertions.sh # Assertion functions │ ├── assert_*.sh # Specialized assertions @@ -141,10 +141,10 @@ Comprehensive validation before releasing. Specialized agents you can consult using the Task tool: -### Bash 3.2+ Expert -**When to use:** Reviewing code for Bash 3.2+ compatibility +### Bash 3.0+ Expert +**When to use:** Reviewing code for Bash 3.0+ compatibility **Expertise:** Identifying incompatible features, suggesting portable alternatives -**Invoke:** Use Task tool with subagent_type="bash-3.2-expert" +**Invoke:** Use Task tool with subagent_type="bash-3.0-expert" ### Code Reviewer **When to use:** Before committing, for comprehensive code review @@ -185,7 +185,7 @@ Complete end-to-end workflow from issue to PR: ### Bash Style @.claude/rules/bash-style.md -- Bash 3.2+ compatibility (critical!) +- Bash 3.0+ compatibility (critical!) - ShellCheck compliance - Function documentation - Naming conventions @@ -208,7 +208,7 @@ Complete end-to-end workflow from issue to PR: ### `src/**/*.sh` - Small, portable functions -- Bash 3.2+ compatibility (no associative arrays, no `[[`, no `${var,,}`) +- Bash 3.0+ compatibility (no associative arrays, no `[[`, no `${var,,}`) - Proper namespacing (`bashunit::*`) - No external dependencies in core - Function documentation required @@ -235,7 +235,7 @@ Complete end-to-end workflow from issue to PR: ### Never: - Invent commands/features not in the codebase -- Break Bash 3.2+ compatibility +- Break Bash 3.0+ compatibility - Skip tests or quality checks - Change public API without docs/CHANGELOG - Use speculative/unproven patterns @@ -249,7 +249,7 @@ Complete end-to-end workflow from issue to PR: - Keep tests passing during REFACTOR - Run quality checks before committing - Update CHANGELOG.md for user-visible changes -- Maintain Bash 3.2+ compatibility +- Maintain Bash 3.0+ compatibility ## Definition of Done @@ -258,7 +258,7 @@ Before marking work complete: - ✅ `make sa` passes (ShellCheck) - ✅ `make lint` passes (EditorConfig) - ✅ Code formatted (`shfmt -w .`) -- ✅ Bash 3.2+ compatible +- ✅ Bash 3.0+ compatible - ✅ Parallel tests passing (`./bashunit --parallel tests/`) - ✅ CHANGELOG.md updated (if user-facing changes) - ✅ Documentation updated (if needed) diff --git a/.claude/GETTING_STARTED.md b/.claude/GETTING_STARTED.md index 1536fac8..951a9484 100644 --- a/.claude/GETTING_STARTED.md +++ b/.claude/GETTING_STARTED.md @@ -15,7 +15,7 @@ Welcome! This 5-minute guide will get you started with the custom Claude Code co - `/gh-issue 42` - Complete GitHub issue → PR workflow 📚 **Code Standards** - Automatic enforcement: -- Bash 3.2+ compatibility +- Bash 3.0+ compatibility - TDD methodology (RED → GREEN → REFACTOR) - Testing patterns - Quality checks @@ -58,7 +58,7 @@ Claude automatically follows these rules (from `.claude/CLAUDE.md`): - Minimal implementation - Refactor while green -**Bash 3.2+ Compatible:** +**Bash 3.0+ Compatible:** - No `declare -A` (associative arrays) - No `[[ ]]` (use `[ ]`) - No `${var,,}` (case conversion) @@ -144,7 +144,7 @@ Study existing patterns: ## What Claude Automatically Enforces ✅ **TDD workflow** - Tests before code -✅ **Bash 3.2+ compatibility** - No modern bash features +✅ **Bash 3.0+ compatibility** - No modern bash features ✅ **Quality checks** - make sa && make lint ✅ **Test patterns** - Use existing patterns only ✅ **Commit format** - Conventional commits (no AI mentions!) diff --git a/.claude/QUICK_REFERENCE.md b/.claude/QUICK_REFERENCE.md index 610df5d2..e0d29e6e 100644 --- a/.claude/QUICK_REFERENCE.md +++ b/.claude/QUICK_REFERENCE.md @@ -15,7 +15,7 @@ | File | Purpose | |------|---------| | `CLAUDE.md` | Main project instructions | -| `rules/bash-style.md` | Bash 3.2+ compatibility rules | +| `rules/bash-style.md` | Bash 3.0+ compatibility rules | | `rules/testing.md` | Testing patterns & guidelines | | `rules/tdd-workflow.md` | TDD methodology details | | `AGENTS.md` | Comprehensive workflow guide | @@ -100,7 +100,7 @@ Before marking work complete: **Never:** - Skip task file requirement -- Use Bash 4+ features (macOS = Bash 3.2) +- Use Bash 4+ features (macOS = Bash 3.0) - Break public API without docs - Commit without tests passing - Skip quality checks @@ -110,11 +110,11 @@ Before marking work complete: - Use patterns from `tests/**` and `src/**` - Update task file logbook - Run tests after every change -- Bash 3.2+ compatible code +- Bash 3.0+ compatible code -## 🔍 Bash 3.2+ Compatibility +## 🔍 Bash 3.0+ Compatibility -| ❌ Don't Use (Bash 4+) | ✅ Use Instead (Bash 3.2+) | +| ❌ Don't Use (Bash 4+) | ✅ Use Instead (Bash 3.0+) | |------------------------|----------------------------| | `declare -A map` | Indexed arrays or workarounds | | `[[ "$var" == "x" ]]` | `[ "$var" = "x" ]` | diff --git a/.claude/README.md b/.claude/README.md index ddba66f1..84f4b100 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -8,7 +8,7 @@ This directory contains custom Claude Code configuration to enhance AI-assisted .claude/ ├── CLAUDE.md # Main project instructions (read this first!) ├── rules/ # Modular rules by topic -│ ├── bash-style.md # Bash 3.2+ compatibility & style +│ ├── bash-style.md # Bash 3.0+ compatibility & style │ ├── testing.md # Testing patterns & guidelines │ └── tdd-workflow.md # TDD Red-Green-Refactor cycle ├── skills/ # Custom reusable workflows @@ -90,7 +90,7 @@ python .claude/agents/examples/pr-validator.py ### 2. Rules (Modular Guidelines) #### `rules/bash-style.md` -- **Bash 3.2+ compatibility** (critical for macOS) +- **Bash 3.0+ compatibility** (critical for macOS) - Coding standards - ShellCheck compliance - Documentation patterns @@ -252,13 +252,13 @@ Workflow steps... For specific domains, create custom agents: ```markdown -# .claude/agents/bash-3-expert/agent.md +# .claude/agents/bash-3.0-expert/agent.md -You are a Bash 3.2 compatibility expert for bashunit. +You are a Bash 3.0 compatibility expert for bashunit. Your expertise: - Identify Bash 4+ features -- Suggest Bash 3.2 alternatives +- Suggest Bash 3.0 alternatives - Explain compatibility trade-offs When consulted: @@ -356,7 +356,7 @@ jobs: /tdd-cycle # Test rules are followed -# (Claude should enforce Bash 3.2+ compatibility) +# (Claude should enforce Bash 3.0+ compatibility) # Test agents run python .claude/agents/examples/tdd-bot.py --help diff --git a/.claude/agents/bash-3.2-expert/agent.md b/.claude/agents/bash-3.0-expert/agent.md similarity index 83% rename from .claude/agents/bash-3.2-expert/agent.md rename to .claude/agents/bash-3.0-expert/agent.md index 3c174df6..f0da8eda 100644 --- a/.claude/agents/bash-3.2-expert/agent.md +++ b/.claude/agents/bash-3.0-expert/agent.md @@ -1,23 +1,23 @@ -# Bash 3.2+ Compatibility Expert +# Bash 3.0+ Compatibility Expert -You are a Bash 3.2+ compatibility expert for the bashunit project. +You are a Bash 3.0+ compatibility expert for the bashunit project. ## Your Expertise You specialize in: -- Bash 3.2+ compatibility (macOS default) +- Bash 3.0+ compatibility (macOS default) - Identifying Bash 4.0+ features -- Providing Bash 3.2 alternatives +- Providing Bash 3.0 alternatives - ShellCheck compliance - Portable shell scripting ## When You're Consulted Developers will ask you to: -- Review code for Bash 3.2+ compatibility +- Review code for Bash 3.0+ compatibility - Identify incompatible features - Suggest portable alternatives -- Explain why certain features don't work in Bash 3.2 +- Explain why certain features don't work in Bash 3.0 - Fix compatibility issues ## Critical Knowledge @@ -30,7 +30,7 @@ Developers will ask you to: declare -A map map["key"]="value" -# ✅ DO (Bash 3.2+) +# ✅ DO (Bash 3.0+) # Use indexed arrays or alternative data structures declare -a keys=("key1" "key2") declare -a values=("val1" "val2") @@ -110,7 +110,7 @@ When reviewing code: - Explain why it's incompatible - State which Bash version introduced it -3. **Provide Bash 3.2 alternative** +3. **Provide Bash 3.0 alternative** - Show working alternative code - Explain any trade-offs - Ensure it's tested and verified @@ -145,18 +145,18 @@ Found 3 Bash 4+ compatibility issues: After fixes, run: shellcheck -x file.sh - bash --version # Verify 3.2 compatibility + bash --version # Verify 3.0 compatibility ``` ## Testing Compatibility Suggest testing approaches: ```bash -# Test on macOS (usually Bash 3.2) +# Test on macOS (usually Bash 3.0) bash --version # Run with older bash if available -bash-3.2 script.sh +bash-3.0 script.sh # Use shellcheck with appropriate shell directive # shellcheck shell=bash @@ -166,7 +166,7 @@ bash-3.2 script.sh ### Loops ```bash -# ✅ Bash 3.2 compatible +# ✅ Bash 3.0 compatible for item in "${array[@]}"; do echo "$item" done @@ -178,17 +178,17 @@ done < file ### String Manipulation ```bash -# ✅ Substring (works in 3.2) +# ✅ Substring (works in 3.0) substring="${string:5:3}" -# ✅ Remove prefix/suffix (works in 3.2) +# ✅ Remove prefix/suffix (works in 3.0) filename="${path##*/}" extension="${filename##*.}" ``` ### Arrays ```bash -# ✅ Array basics (works in 3.2) +# ✅ Array basics (works in 3.0) declare -a array=("item1" "item2") length="${#array[@]}" last="${array[${#array[@]}-1]}" @@ -196,13 +196,13 @@ last="${array[${#array[@]}-1]}" ## Resources -- Bash 3.2 was released in 2006 (macOS default) +- Bash 3.0 was released in 2004 - Major features added in Bash 4.0+ (2009) are not available -- Always test on macOS or with Bash 3.2 +- Always test on macOS or with Bash 3.0 ## Key Principles -1. **Assume Bash 3.2** - It's the lowest common denominator +1. **Assume Bash 3.0** - It's the lowest common denominator 2. **Test on macOS** - Most likely to catch issues 3. **Use ShellCheck** - It helps catch compatibility issues 4. **Prefer POSIX** - When possible, use POSIX-compatible constructs @@ -221,4 +221,4 @@ If compatibility cannot be achieved: fi ``` -Your goal: Help maintain bashunit's Bash 3.2+ compatibility while writing clean, readable code. +Your goal: Help maintain bashunit's Bash 3.0+ compatibility while writing clean, readable code. diff --git a/.claude/agents/code-reviewer/agent.md b/.claude/agents/code-reviewer/agent.md index a86a052d..27d6da13 100644 --- a/.claude/agents/code-reviewer/agent.md +++ b/.claude/agents/code-reviewer/agent.md @@ -7,7 +7,7 @@ You are a code reviewer for the bashunit project, specializing in validating cod You review code for: - Project standard compliance - Code quality and readability -- Bash 3.2+ compatibility +- Bash 3.0+ compatibility - Security issues - Performance concerns - Test coverage @@ -24,7 +24,7 @@ You review code for: - ✅ Variables quoted (`"$var"` not `$var`) - ✅ Error handling (`set -euo pipefail` where appropriate) - ✅ ShellCheck compliance -- ✅ Bash 3.2+ compatibility (no `[[`, `declare -A`, etc.) +- ✅ Bash 3.0+ compatibility (no `[[`, `declare -A`, etc.) ### 2. Testing Standards (@.claude/rules/testing.md) @@ -90,7 +90,7 @@ Lines: +150 -0 **Bash Style:** - Line 15: Missing function documentation - Line 42: Variable not quoted: `$user_input` -- Line 58: Using `[[` instead of `[` (Bash 3.2 incompatible) +- Line 58: Using `[[` instead of `[` (Bash 3.0 incompatible) **Testing:** - Missing test for error case (when file not found) @@ -115,7 +115,7 @@ result="$user_input" # ❌ Line 58: Bash 4+ feature if [[ "$var" == "value" ]]; then -# ✅ Fix: Use Bash 3.2 compatible syntax +# ✅ Fix: Use Bash 3.0 compatible syntax if [ "$var" = "value" ]; then ``` @@ -174,7 +174,7 @@ Use this for each review: - [ ] Functions documented - [ ] Variables quoted - [ ] ShellCheck clean -- [ ] Bash 3.2+ compatible +- [ ] Bash 3.0+ compatible ### Testing - [ ] Test file names correct (_test.sh) @@ -211,7 +211,7 @@ Use this for each review: **Critical (Block Merge):** - Security vulnerabilities -- Bash 3.2 incompatibility +- Bash 3.0 incompatibility - Breaking changes without docs - Failing tests @@ -253,7 +253,7 @@ Complexity: Medium 2. **Compatibility: Bash 4+ Feature** (Line 78) Using associative array (Bash 4.0+) - Fix: See @.claude/agents/bash-3.2-expert for alternatives + Fix: See @.claude/agents/bash-3.0-expert for alternatives ## Major Issues ⚠️ diff --git a/.claude/agents/performance-optimizer/agent.md b/.claude/agents/performance-optimizer/agent.md index 67f4a6e6..a35c731e 100644 --- a/.claude/agents/performance-optimizer/agent.md +++ b/.claude/agents/performance-optimizer/agent.md @@ -1,12 +1,12 @@ # Performance Optimizer Agent -You are a Bash 3.2+ performance optimization expert for the bashunit project. +You are a Bash 3.0+ performance optimization expert for the bashunit project. ## Your Expertise You specialize in: - Identifying performance bottlenecks in Bash scripts -- Optimizing while maintaining Bash 3.2+ compatibility +- Optimizing while maintaining Bash 3.0+ compatibility - Avoiding expensive operations (subshells, external commands, pipes) - Using built-in commands efficiently - Benchmarking and measuring improvements @@ -66,7 +66,7 @@ upper=$(echo "$string" | tr '[:lower:]' '[:upper:]') # ✅ FAST: Bash built-ins [[ "$string" =~ pattern ]] # Pattern matching length="${#string}" # String length -# Note: Case conversion requires external command in Bash 3.2 +# Note: Case conversion requires external command in Bash 3.0 upper=$(printf '%s' "$string" | tr '[:lower:]' '[:upper:]') ``` @@ -184,9 +184,9 @@ while IFS= read -r line; do done < file.txt # ✅ FAST: If you need the content -mapfile -t lines < file.txt # Bash 4.0+, not available in 3.2! +mapfile -t lines < file.txt # Bash 4.0+, not available in 3.0! -# ✅ FAST (Bash 3.2): Read into array +# ✅ FAST (Bash 3.0): Read into array lines=() while IFS= read -r line; do lines+=("$line") @@ -212,7 +212,7 @@ for item in "${array[@]}"; do fi done -# ✅ FAST (Bash 3.2): Use case for pattern matching +# ✅ FAST (Bash 3.0): Use case for pattern matching filtered=() for item in "${array[@]}"; do case "$item" in @@ -232,7 +232,7 @@ result=$(echo "$string" | awk '{print tolower($0)}') result="${string/foo/bar}" # Replace first occurrence result="${string//foo/bar}" # Replace all occurrences -# For case conversion (requires external in Bash 3.2): +# For case conversion (requires external in Bash 3.0): # Use tr once, not in loop result=$(printf '%s' "$string" | tr '[:upper:]' '[:lower:]') ``` @@ -370,7 +370,7 @@ CRITICAL: Ensure optimization didn't break functionality! 1. Run all tests 2. Compare outputs (before/after) 3. Check edge cases -4. Verify Bash 3.2 compatibility +4. Verify Bash 3.0 compatibility ``` ## Optimization Examples @@ -483,14 +483,14 @@ Recommendation: declare -A cache cache["key"]="value" -# Slower but Bash 3.2 compatible +# Slower but Bash 3.0 compatible declare -a cache_keys=("key") declare -a cache_vals=("value") Recommendation: -- bashunit requires Bash 3.2+ compatibility +- bashunit requires Bash 3.0+ compatibility - Always choose portable option -- Optimize within Bash 3.2 constraints +- Optimize within Bash 3.0 constraints ``` ## Performance Checklist @@ -530,10 +530,10 @@ When reviewing code: - [ ] Batch operations when possible - [ ] Avoiding repeated file access -### Bash 3.2 Compatibility -- [ ] All optimizations work in 3.2 +### Bash 3.0 Compatibility +- [ ] All optimizations work in 3.0 - [ ] No Bash 4+ features used -- [ ] Tested on macOS (Bash 3.2) +- [ ] Tested on macOS (Bash 3.0) ``` ## Example Performance Review @@ -593,7 +593,7 @@ When optimizing: 4. **Benchmark changes** - Measure improvement 5. **Verify correctness** - Tests must still pass 6. **Document trade-offs** - Explain performance choices -7. **Maintain compatibility** - Stay Bash 3.2+ compatible +7. **Maintain compatibility** - Stay Bash 3.0+ compatible ## Key Principles @@ -602,6 +602,6 @@ When optimizing: - **Optimize hot paths** - 80/20 rule applies - **Maintain readability** - Add comments for complex optimizations - **Test thoroughly** - Optimization can break things -- **Stay compatible** - Bash 3.2+ always +- **Stay compatible** - Bash 3.0+ always -Your goal: Make bashunit faster while maintaining correctness, readability, and Bash 3.2+ compatibility. +Your goal: Make bashunit faster while maintaining correctness, readability, and Bash 3.0+ compatibility. diff --git a/.claude/commands/gh-issue.md b/.claude/commands/gh-issue.md index 571da7e6..8a82b359 100644 --- a/.claude/commands/gh-issue.md +++ b/.claude/commands/gh-issue.md @@ -68,7 +68,7 @@ Fetch a GitHub issue, create task file and branch, implement it following TDD, a 7. **Read project context**: - @.claude/CLAUDE.md - Primary project instructions - - @.claude/rules/bash-style.md - Bash 3.2+ compatibility + - @.claude/rules/bash-style.md - Bash 3.0+ compatibility - @.claude/rules/testing.md - Testing patterns - @AGENTS.md - Additional TDD guidelines @@ -121,7 +121,7 @@ Fetch a GitHub issue, create task file and branch, implement it following TDD, a 1. Study existing patterns in tests/unit/assert_test.sh 2. Start with simplest test (#1) 3. Implement following RED → GREEN → REFACTOR - 4. Ensure Bash 3.2+ compatible throughout + 4. Ensure Bash 3.0+ compatible throughout ``` **Optional:** Create `.tasks/YYYY-MM-DD--.md` for complex work to track detailed progress. @@ -163,7 +163,7 @@ Fetch a GitHub issue, create task file and branch, implement it following TDD, a - ✅ Write tests BEFORE implementation - ✅ Minimal code in GREEN phase - ✅ Keep tests passing during REFACTOR - - ✅ Follow Bash 3.2+ compatibility (@.claude/rules/bash-style.md) + - ✅ Follow Bash 3.0+ compatibility (@.claude/rules/bash-style.md) 13. **Run full test suite** frequently: ```bash @@ -304,7 +304,7 @@ Fetch a GitHub issue, create task file and branch, implement it following TDD, a ## Checklist - [ ] Follows TDD workflow (tests written first) - - [ ] Bash 3.2+ compatible + - [ ] Bash 3.0+ compatible - [ ] Task file complete: `.tasks/YYYY-MM-DD--.md` - [ ] Two-way sync checked (AGENTS.md ↔ .github/copilot-instructions.md) - [ ] Breaking changes documented (if any) @@ -372,7 +372,7 @@ Would you like me to: - [ ] Parallel tests passing (`./bashunit --parallel tests/`) - [ ] Quality checks passing (`make sa && make lint`) - [ ] Code formatted (`shfmt -w .`) -- [ ] Bash 3.2+ compatible +- [ ] Bash 3.0+ compatible - [ ] CHANGELOG.md updated - [ ] Documentation updated (if needed) - [ ] Commit created with conventional format @@ -382,9 +382,9 @@ Would you like me to: ## Important Notes -### Bash 3.2+ Compatibility +### Bash 3.0+ Compatibility -**Critical:** bashunit must work on macOS default Bash 3.2. Check @.claude/rules/bash-style.md +**Critical:** bashunit must work on macOS default Bash 3.0. Check @.claude/rules/bash-style.md **Forbidden features:** - ❌ `declare -A` (associative arrays) @@ -419,7 +419,7 @@ All code must: - ✅ Be formatted (`shfmt -w .`) - ✅ Have tests (90%+ coverage) - ✅ Follow existing patterns -- ✅ Work in Bash 3.2+ +- ✅ Work in Bash 3.0+ ### Configuration Sync diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md index ccf7d87d..08dee68e 100644 --- a/.claude/rules/bash-style.md +++ b/.claude/rules/bash-style.md @@ -6,9 +6,9 @@ paths: # Bash Style & Compatibility Rules -## Bash 3.2+ Compatibility (Critical) +## Bash 3.0+ Compatibility (Critical) -bashunit must work on **Bash 3.2+** (macOS default). These features are **prohibited**: +bashunit must work on **Bash 3.0+** (macOS default). These features are **prohibited**: ### ❌ Forbidden Features diff --git a/.claude/skills/add-assertion/SKILL.md b/.claude/skills/add-assertion/SKILL.md index 01e802fe..8a9902aa 100644 --- a/.claude/skills/add-assertion/SKILL.md +++ b/.claude/skills/add-assertion/SKILL.md @@ -384,7 +384,7 @@ Provide progress updates: ## Quality Standards **All assertions must:** -- Follow Bash 3.2+ compatibility (@.claude/rules/bash-style.md) +- Follow Bash 3.0+ compatibility (@.claude/rules/bash-style.md) - Have comprehensive tests (success, failure, edge cases) - Have clear, helpful error messages - Be documented with examples diff --git a/.claude/skills/pre-release/SKILL.md b/.claude/skills/pre-release/SKILL.md index 87677874..a604db50 100644 --- a/.claude/skills/pre-release/SKILL.md +++ b/.claude/skills/pre-release/SKILL.md @@ -121,7 +121,7 @@ shfmt -l . ### 6. Compatibility -**Verify Bash 3.2+ compatibility:** +**Verify Bash 3.0+ compatibility:** ```bash # Check for Bash 4+ features @@ -139,7 +139,7 @@ grep -r "\${.*,,}" src/ # Case conversion (Bash 4+) **If available, test on multiple platforms:** ```bash -# macOS (Bash 3.2) +# macOS (Bash 3.0) ./bashunit tests/ # Linux (if Docker available) @@ -149,7 +149,7 @@ make test/alpine ``` **Verify:** -- ✅ Works on macOS (Bash 3.2) +- ✅ Works on macOS (Bash 3.0) - ✅ Works on Linux - ✅ No hardcoded paths - ✅ No platform assumptions @@ -368,7 +368,7 @@ Date: 2026-02-09 • Examples: All working ✅ Compatibility - • Bash 3.2+: Compatible + • Bash 3.0+: Compatible • No Bash 4+ features found ✅ Security @@ -423,7 +423,7 @@ Next Steps: - ✅ Git state clean - ✅ CI passing - ✅ No known security issues -- ✅ Bash 3.2+ compatible +- ✅ Bash 3.0+ compatible ## After Pre-Release Validation diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 86835dbb..586ec510 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) @@ -232,12 +232,34 @@ Supported: Linux (Ubuntu, Alpine), macOS, Windows (WSL/Git Bash) ```bash # Docker testing -make test/alpine +make docker/alpine +make docker/ubuntu # NixOS nix-shell --pure --run "./bashunit --simple --parallel" ``` +### Testing with Bash 3.0 + +bashunit supports Bash 3.0+. A dedicated Dockerfile is provided at `local/Dockerfile.bash3` to compile Bash 3.0 from source for compatibility testing. This is the same setup used in CI. + +```bash +# Build the Bash 3.0 image +docker build -t bashunit-bash3 -f local/Dockerfile.bash3 . + +# Run all tests under Bash 3.0 +docker run --rm -v "$(pwd)":/bashunit -w /bashunit bashunit-bash3 \ + /opt/bash-3.0/bin/bash ./bashunit tests/ + +# Run tests in parallel +docker run --rm -v "$(pwd)":/bashunit -w /bashunit bashunit-bash3 \ + /opt/bash-3.0/bin/bash ./bashunit --parallel tests/ + +# Open an interactive Bash 3.0 shell for debugging +docker run --rm -it -v "$(pwd)":/bashunit -w /bashunit bashunit-bash3 \ + /opt/bash-3.0/bin/bash +``` + ### Writing Tests 1. Create files ending with `_test.sh` diff --git a/.github/ISSUE_TEMPLATE/BUG.md b/.github/ISSUE_TEMPLATE/BUG.md index 0e632865..2f81cb7e 100644 --- a/.github/ISSUE_TEMPLATE/BUG.md +++ b/.github/ISSUE_TEMPLATE/BUG.md @@ -14,7 +14,7 @@ labels: bug | Q | A | |------------------|--------------------------| | OS | macOS / Linux / Windows | -| Shell & version | sh 2.0 / bash 3.2 / ... | +| Shell & version | sh 2.0 / bash 3.0 / ... | | bashunit version | x.y.z | #### Summary 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..65b99f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ ## 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 - - Expert agents for Bash 3.2+ compatibility, code review, TDD coaching, test architecture, and performance + - Expert agents for Bash 3.0+ compatibility, code review, TDD coaching, test architecture, and performance - GitHub issue → PR workflow command - Consolidated AI developer tool instructions into `.claude/CLAUDE.md` - Display test output (stdout/stderr) on failure for 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..340e22fc 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 @@ -12,14 +12,14 @@ function _check_bash_version() { # Checks if the special Bash array BASH_VERSINFO exists. This array is only defined in Bash. current_version="${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" else - # If not in Bash (e.g., running from Zsh). The pipeline extracts just the major.minor version (e.g., 3.2). + # If not in Bash (e.g., running from Zsh). The pipeline extracts just the major.minor version (e.g., 3.0). 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,15 +88,15 @@ bashunit::clock::init _SUBCOMMAND="" case "${1:-}" in - test|bench|doc|init|learn|upgrade|assert) + test | bench | doc | init | learn | upgrade | assert) _SUBCOMMAND="$1" shift ;; - -v|--version) + -v | --version) bashunit::console_header::print_version exit 0 ;; - -h|--help) + -h | --help) bashunit::console_header::print_help exit 0 ;; @@ -116,11 +116,11 @@ 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 "$@" ;; + 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 "$@" ;; + assert) bashunit::main::cmd_assert "$@" ;; esac 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..c6e23bfa 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 $? } @@ -217,9 +233,11 @@ function assert_not_same() { function assert_contains() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -242,28 +260,33 @@ 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 } function assert_not_contains() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -282,13 +305,15 @@ function assert_not_contains() { function assert_matches() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' 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 @@ -303,13 +328,15 @@ function assert_matches() { function assert_not_matches() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' 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 +363,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 +406,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 +435,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 +454,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 +474,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 +491,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 +511,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 @@ -505,9 +532,11 @@ function assert_command_not_found() { function assert_string_starts_with() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -545,9 +574,11 @@ function assert_string_not_starts_with() { function assert_string_ends_with() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -566,9 +597,11 @@ function assert_string_ends_with() { function assert_string_not_ends_with() { bashunit::assert::should_skip && return 0 + local IFS=$' \t\n' local expected="$1" - local actual_arr=("${@:2}") + local -a actual_arr + actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -663,11 +696,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 +710,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 +721,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'