Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

Works on macOS default bash (3.2) and older. **Prohibited features:**
- `printf -v` (Bash 3.1+)
- `+=` append assignment (Bash 3.1+)
- `x+=y` string append assignment (Bash 3.1+)
- `arr+=(x)` array append (a Bash 3.0 **parse** error — it kills the file even in dead code)
- `declare -A` (associative arrays - Bash 4.0+)
- `[[ ]]` (use `[ ]` instead — house style, not a version limit)
- `[[ =~ ]]` (exists on 3.0, but 3.2 changed quoted-pattern semantics)
Expand Down
3 changes: 2 additions & 1 deletion .claude/rules/bash-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ enforced mechanically by `tests/unit/bash_compatibility_test.sh`, which greps
| Feature | Bash ver | Alternative |
|---------|----------|-------------|
| `printf -v` | 3.1+ | Return-slot pattern (below) |
| `+=` (append assign) | 3.1+ | `var="$var$more"`, `arr[${#arr[@]}]=x` |
| `x+=y` (string append) | 3.1+ | `var="$var$more"` |
| `arr+=(x)` (array append) | 3.1+, and a 3.0 **parse** error | `arr[${#arr[@]}]=x` |
| `declare -A` (associative arrays) | 4.0+ | Parallel indexed arrays |
| `${var,,}` / `${var^^}` (case) | 4.0+ | `tr '[:upper:]' '[:lower:]'` |
| `${array[-1]}` (negative index) | 4.3+ | `${array[${#array[@]}-1]}` |
Expand Down
73 changes: 60 additions & 13 deletions tests/unit/project/bash_compatibility_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,25 @@
# example preferring `[ ]` over `[[ ]]`) are not compatibility rules and are not
# enforced by these tests -- `[[ ]]` works on every bash we support.

# Returns offending "file:line: text" for a pattern, skipping comment lines so a
# rule quoted in documentation or in a comment is not an error.
# Returns offending "file:line: text" for a pattern under a directory, skipping
# comment lines so a rule quoted in documentation or in a comment is not an
# error.
function bashunit::compat::offenders_in() {
grep -rnE "$2" "$1" 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true
}

function bashunit::compat::offenders() {
grep -rnE "$1" src/ 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true
bashunit::compat::offenders_in "src/" "$1"
}

# The statement boundary both append rules anchor at: `cmd; x+=y`,
# `if c; then x+=y; fi` and `for i; do x+=y; done` are all appends, and a
# `^`-only anchor walked straight past every one of them.
function bashunit::compat::append_prefix_pattern() {
local pattern='(^|[;&|]|\bthen\b|\bdo\b|\belse\b)[[:space:]]*'
pattern="$pattern"'(local[[:space:]]+|declare[[:space:]]+[^[:space:]]+[[:space:]]+|export[[:space:]]+)?'
pattern="$pattern"'[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+='
echo "$pattern"
}

# Bash 3.0 does not expand a compound array assignment attached to `local`:
Expand Down Expand Up @@ -55,20 +70,52 @@ function test_src_has_no_printf_assignment() {
assert_empty "$(bashunit::compat::offenders 'printf[[:space:]]+(-[a-zA-Z]*v)')"
}

# `+=` append assignment is Bash 3.1+. Use `var="$var$more"` for strings and
# `arr[${#arr[@]}]=x` to append to an array. Arithmetic `(( x += 1 ))` is fine on
# 3.0, so only assignment-position `+=` is matched here.
function test_src_has_no_append_assignment() {
# Anchored at a statement boundary, not just start-of-line: `cmd; x+=y`,
# `if c; then x+=y; fi` and `for i; do x+=y; done` are all Bash 3.1+ append
# assignments, and a `^`-only anchor walked straight past every one of them.
local pattern='(^|[;&|]|\bthen\b|\bdo\b|\belse\b)[[:space:]]*'
pattern="$pattern"'(local[[:space:]]+|declare[[:space:]]+[^[:space:]]+[[:space:]]+|export[[:space:]]+)?'
pattern="$pattern"'[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+='
# String `x+=y` is Bash 3.1+. Use `var="$var$more"`. Arithmetic `(( x += 1 ))`
# is fine on 3.0, so only assignment-position `+=` is matched here.
#
# Measured on a real 3.00.22: this form parses on 3.0 and is simply inert in a
# branch that shell never takes, which is why it is a separate, weaker rule
# from the array form below (#1349).
function test_src_has_no_string_append_assignment() {
local pattern
pattern="$(bashunit::compat::append_prefix_pattern)"'([^(]|$)'

assert_empty "$(bashunit::compat::offenders "$pattern")"
}

# Array `arr+=(x)` is a Bash 3.0 **parse** error, not a runtime one: it kills
# the whole file on 3.0 even inside `if false; then … fi` or an uncalled
# function, and parses fine on 3.2 -- so a green macOS run says nothing about
# it (#1349). No version guard can make it safe; use `arr[${#arr[@]}]=x`.
function test_src_has_no_array_append_assignment() {
local pattern
pattern="$(bashunit::compat::append_prefix_pattern)"'\('

assert_empty "$(bashunit::compat::offenders "$pattern")"
}

# The two rules must not answer for each other, or the split means nothing: a
# rule that matched both would keep calling a parse error a runtime concern.
function test_the_append_rules_each_match_only_their_own_construct() {
local dir
dir="$(bashunit::temp_dir)"
printf 'function f() {\n x+=y\n}\n' >"$dir/string_append.sh"
printf 'function f() {\n arr+=(x)\n}\n' >"$dir/array_append.sh"

local string_pattern array_pattern
string_pattern="$(bashunit::compat::append_prefix_pattern)"'([^(]|$)'
array_pattern="$(bashunit::compat::append_prefix_pattern)"'\('

local string_hits array_hits
string_hits="$(bashunit::compat::offenders_in "$dir" "$string_pattern")"
array_hits="$(bashunit::compat::offenders_in "$dir" "$array_pattern")"

assert_contains "string_append.sh" "$string_hits"
assert_not_contains "array_append.sh" "$string_hits"
assert_contains "array_append.sh" "$array_hits"
assert_not_contains "string_append.sh" "$array_hits"
}

# `[[ =~ ]]` exists on Bash 3.0, but 3.2 changed whether a quoted right-hand side
# is a regex or a literal, so the same match behaves differently across supported
# versions. Regex matching goes through `grep -E` -- see perf-fork-budget.md.
Expand Down
Loading