Skip to content
Merged
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
64 changes: 64 additions & 0 deletions tests/unit/bash_compatibility_test.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
#!/usr/bin/env bash

# Guards the minimum-bash contract mechanically.
#
# The Bash 3.0 CI job only catches a too-new construct when a test happens to
# EXECUTE the line: `${var,,}` inside a rarely-taken branch parses fine and fails
# at runtime, so it can ship. These greps close that gap by rejecting the
# construct at source level regardless of coverage.
#
# Only genuinely version-breaking syntax belongs here. Style preferences (for
# 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 the `learn` tutorial text is not an error.
function bashunit::compat::offenders() {
grep -rnE "$1" src/ 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true
}

# Bash 3.0 does not expand a compound array assignment attached to `local`:
# `local arr=(a b)` stores the literal string "(a b)" as a single element
# instead of building the array. Every bash >= 3.2 does the right thing, so
Expand All @@ -15,3 +32,50 @@ function test_src_has_no_compound_array_assignment_attached_to_local() {

assert_empty "$offenders"
}

# Associative arrays are Bash 4.0+. Use parallel indexed arrays instead.
function test_src_has_no_associative_arrays() {
assert_empty "$(bashunit::compat::offenders '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*A)')"
}

# ${var,,} / ${var^^} case conversion is Bash 4.0+. Use tr instead.
function test_src_has_no_parameter_expansion_case_conversion() {
assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?(,,|\^\^|,|\^)\}')"
}

# ${array[-1]} is Bash 4.3+. Use ${array[${#array[@]}-1]} instead.
function test_src_has_no_negative_array_subscripts() {
assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*\[-[0-9]')"
}

# &>> is Bash 4.0+. Use `>> file 2>&1` instead.
function test_src_has_no_append_both_streams_redirect() {
assert_empty "$(bashunit::compat::offenders '&>>')"
}

# BASHPID is Bash 4.0+. Subshells inherit $$, so a per-worker unique token needs
# a fork (mktemp) or an externally assigned ordinal -- see #851.
function test_src_has_no_bashpid() {
assert_empty "$(bashunit::compat::offenders 'BASHPID')"
}

# mapfile/readarray are Bash 4.0+. Use a `while IFS= read -r` loop instead.
function test_src_has_no_mapfile_or_readarray() {
assert_empty "$(bashunit::compat::offenders '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)')"
}

# declare -n / local -n (namerefs) are Bash 4.3+. Use the return-slot pattern
# documented in .claude/rules/bash-style.md instead.
function test_src_has_no_namerefs() {
assert_empty "$(bashunit::compat::offenders '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*n)[[:space:]]')"
}

# coproc is Bash 4.0+.
function test_src_has_no_coproc() {
assert_empty "$(bashunit::compat::offenders '(^|[^[:alnum:]_])coproc([^[:alnum:]_]|$)')"
}

# ${var@Q} and friends are Bash 4.4+.
function test_src_has_no_parameter_transformations() {
assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*@[QEPAKa]\}')"
}
Loading