diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fec0b6..ec407078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +- A helper can now ship a faster body for a newer Bash and keep the Bash 3.0 one as a fallback, chosen once at load time. The floor does not move: 3.0 keeps working and keeps being tested. The compatibility rules now record each construct's minimum version instead of only banning it, and allow one only inside a matching version gate — never for a construct that is a parse error on the floor, since those kill the file even in a branch that shell never takes. See `adrs/adr-013-bash-version-gated-fast-paths.md` (#1352) - Performance: a file defining `set_up_before_script` or `tear_down_after_script` costs about 5.6ms less on Bash 3.2, and a test rendered with `--show-execution-time` about 0.74ms less. Four clock reads per file and the per-test padding still went through a capture subshell, next to return-slot variants that were already there (#1348) - Performance: startup is faster on suites of plain test files. Printing "Running N tests" sourced every file a second time and re-ran every data provider before the run began. A file whose functions the provider scan can already see is counted from that scan instead: over this repo's 241 files the counting pass went from 1.72s to 1.51s, and 124 of them no longer source or run a provider twice. Files with a data provider, a heredoc, a multi-line string, an `eval`, a nested `source` or a conditional definition keep the old path, so the count can never disagree with the run (#1347) - Performance: a sequential run is about 0.7ms faster per test — a 500-test file went from 2.75s to 2.40s on macOS arm64, bash 3.2. Every test forked a subshell to read its own definition line, for a `:` that only a failure message and a report row ever use. It is resolved on demand now (#1346) diff --git a/adrs/adr-011-source-layout-and-build-pipeline.md b/adrs/adr-011-source-layout-and-build-pipeline.md index 5dfd5355..3aec0559 100644 --- a/adrs/adr-011-source-layout-and-build-pipeline.md +++ b/adrs/adr-011-source-layout-and-build-pipeline.md @@ -49,8 +49,8 @@ Sixteen, in load order. The order is the dependency layering: leaves first. | # | Module | Files | Lines | Owns | |---|---|---|---|---| | 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** | -| 2 | `system/` | 4 | 189 | capability probing: OS, `command -v`, small I/O | -| 3 | `util/` | 4 | 474 | computation: strings, arithmetic, time | +| 2 | `system/` | 5 | 236 | capability probing: OS, `command -v`, small I/O, Bash version tiers | +| 3 | `util/` | 4 | 735 | computation: strings, arithmetic, time | | 4 | `api/` | 5 | 205 | the surface a user's test file calls (except assertions) | | 5 | `config/` | 5 | 1163 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache, named suites | | 6 | `coverage/` | 16 | 3277 | line/branch tracking, diff coverage and the five report formats | diff --git a/adrs/adr-013-bash-version-gated-fast-paths.md b/adrs/adr-013-bash-version-gated-fast-paths.md new file mode 100644 index 00000000..5fa5de50 --- /dev/null +++ b/adrs/adr-013-bash-version-gated-fast-paths.md @@ -0,0 +1,123 @@ +# Bash version-gated fast paths + +* Status: accepted +* Date: 2026-09-09 + +## Context and Problem Statement + +bashunit supports Bash 3.0, so it forks where a newer shell has a builtin. Until now there was no way to ship a faster body for a newer shell: `tests/unit/project/bash_compatibility_test.sh` rejects a too-new construct anywhere in `src/`, including inside a branch that Bash 3.0 never takes. The rule is deliberately blunt, because the Bash 3.0 CI job only catches a too-new construct when a test happens to execute the line. + +The cost of not having the mechanism is measured in forks. A fork is worth 1 to 3 ms; the whole per-test framework overhead is around 5 ms. So a single gated helper that removes a fork on the shell most users run is worth more than every micro-optimisation in the same file put together. + +This ADR records the mechanism, and the rule a gate must obey. **The floor does not move: Bash 3.0 keeps working, and keeps being tested.** + +## Decision Drivers + +* A newer shell should use the fastest construct it offers; 3.0 must still run everything. +* The compatibility rules must stay blunt for ungated code. A gate is the only exception, and it has to be a structural fact rather than an author's claim. +* Selection must cost nothing per call. +* A gate may change speed. It may never change observable behaviour. +* The build must emit a gate intact, and the artifact must parse on every supported version. + +## Considered Options + +1. **Load-time `if/else` around two function definitions**, keyed on a flag file that declares one flag per version boundary in use. +2. **Runtime predicate per call**, the pattern the five existing version checks use. +3. **A per-file allow-list** in the compatibility test. +4. **A trailing pragma** such as `# bashunit: bash4` on the offending line. + +## Decision Outcome + +Chosen option: **Option 1**. + +`src/system/bash.sh` declares one flag per boundary in use, named for its tier: `_BASHUNIT_BASH_GE_31`. A leaf module then picks between two bodies at load time: + +```bash +if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + function bashunit::str::lpad_to_slot() { + printf -v _BASHUNIT_STR_LPAD_OUT "%${1}s" "$2" + } +else + function bashunit::str::lpad_to_slot() { + _BASHUNIT_STR_LPAD_OUT=$(printf "%${1}s" "$2") + } +fi +``` + +Measured per-call overhead above a single ungated definition, best of 5 over 100k calls: + +| Shape | 3.0 | 4.0 | 5.2 | 5.3 | +|---|---|---|---|---| +| Top-level `if/else`, definition picked once | +0.09 | -0.05 | 0.00 | -0.07 | +| `case` on a memoized impl, per call | +1.7 | +1.2 | +0.8 | +1.2 | +| Predicate function per call | +17.9 | +8.9 | +5.0 | +4.3 | + +Selection is free; the pattern already in use is the slow one. Converting the five existing runtime predicates is not part of this decision — they are correct, and 0.1% of a test. + +### What may sit behind a gate + +Measured on a real Bash 3.00.22 and 3.2.57, with each construct placed both inside `if false; then … fi` and inside an uncalled function. Dead-code safety did not depend on the shape; the two behaved identically in every case. + +**Parse-time. Can never sit behind a gate**, because they kill the file even where nothing reaches them: `&>>`, `|&`, `;;&`, `;&`, `arr+=(x)`. `arr+=(x)` is the trap, since it is a parse error on 3.0 only and parses fine on 3.2, so a green macOS run says nothing about it. + +**Runtime-only. Safe inside an untaken branch**: `${v,,}`, `${v^^}`, `${v@Q}`, `mapfile`, `declare -A`, `declare -n`, `local -n`, `exec {fd}>`, `printf -v`, `x+=y`, `coproc`, `wait -n`, `BASHPID`, `SRANDOM`, `EPOCHREALTIME`, fractional `read -t`, `${arr[-1]}`, `${ cmd; }`. + +### The rule a gate must obey + +**A gate may change speed. It may never change observable behaviour.** + +That is not a slogan, and the textbook example breaks it: `${v,,}` and `tr '[:upper:]' '[:lower:]'` disagree on non-ASCII. BSD `tr` folds `ÑÜ`, GNU and busybox `tr` do not, and `${v,,}` folds under `C.UTF-8` but not under `C` (#1351). So a gated helper needs either an equivalence test over its real input domain, or a documented contract narrow enough to be portable. + +The first gated helper was chosen to make this easy to hold: both bodies hand the same format and the same value to the same `printf`, so the output is identical by construction and the only difference is the fork. + +A gated helper also needs a **branch-selection test**, asserting the running shell got the body its tier calls for. Without it a gate that silently always falls back passes every equivalence test while delivering none of the speed. Introspect with `type`, not `declare -f`: real Bash 3.0 refuses a `::` name there. + +### Where a gate may live + +In a **leaf module**, at column 0, per ADR-011. Not in an `index.sh`: `build::process_file` emits an aggregator's body before the files it sources, so a gate there works in dev mode and exits 127 from the built artifact on Bash 5.2 while passing on 3.2 — invisible on the reference platform. + +Column 0 is also what the compatibility test recognises. An indented header is a runtime condition inside some other block, so its body is parsed on every shell and reached whenever that block runs; it gates nothing. + +### How the compatibility test enforces it + +Each rule declares the construct's minimum version as a tier: `printf -v` is 31, `declare -A` is 40, `${arr[-1]}` is 43. A construct is allowed only when the offending line sits in the then-branch of a column-0 `if [ "$_BASHUNIT_BASH_GE_NN" = 1 ]; then` whose `NN` is at least that tier, closed by a column-0 `else` or `fi`. + +A tier of 0 means no gate is ever enough. That covers the parse-time constructs, and also the rules whose boundary is not established — `[[ =~ ]]` changed semantics between 3.0 and 3.2 rather than gaining a version, and the temporary-locale prefix is a Bash 5.3.9 segfault, not a feature. + +The tier lives in the flag name so the comparison is textual, and a separate test asserts every flag a gate names is one the flags file declares — a typo would leave the flag empty, the fast body unreachable and the fallback in use everywhere: green, slower, and invisible in review. + +### Positive Consequences + +* A newer shell can skip a fork that the 3.0 floor requires, without moving the floor. +* Selection costs nothing per call, unlike the runtime-predicate pattern. +* The compatibility rules stay blunt for everything that is not gated, and now say what version each construct needs rather than only that it is banned. +* `test_every_src_file_parses_with_the_running_bash` makes the parse-time class explicit on the macOS 3.2 and real-3.0 jobs. + +### Negative Consequences + +* A gated helper is two bodies to keep in step, and only tests hold them together. +* The build embeds both, so a gated function is defined twice by design. `build_test.sh`'s column-0 duplicate guard cannot see either body, so a second guard caps every name at two definitions whatever the indentation. +* The recogniser accepts exactly one header spelling. A near-miss gates nothing, which fails closed but can read as a puzzling rejection. + +## Pros and Cons of the Options + +### Option 1: Load-time `if/else` on a declared flag (chosen) + +* Good, because selection is free and the fast body can remove a fork. +* Good, because the gate is a structural fact the test can recognise, not a claim it has to trust. +* Good, because it survives the build byte-for-byte and the artifact parses on 3.0, 3.2, 4.4, 5.2 and 5.3. +* Bad, because it doubles the bodies of a gated helper. + +### Option 2: Runtime predicate per call + +* Good, because it needs no new mechanism; five already exist. +* Bad, because it costs +17.9 microseconds per call on Bash 3.0, the platform least able to spare it. +* Bad, because the too-new construct still sits in a body the compatibility test rejects, so it does not solve the problem this ADR is about. + +### Option 3: Per-file allow-list + +* Bad, because an ungated construct elsewhere in the same file then ships, which is exactly the gap the rules exist to close. + +### Option 4: Trailing pragma + +* Bad, because it is the author's claim rather than a structural fact, so honouring it is a smuggling path by construction. diff --git a/src/benchmark/results.sh b/src/benchmark/results.sh index aa7d7b4a..5dff3c66 100644 --- a/src/benchmark/results.sh +++ b/src/benchmark/results.sh @@ -76,18 +76,17 @@ function bashunit::benchmark::print_results() { if bashunit::math::is_le "$avg" "$max_ms"; then local raw="≤ ${max_ms}" - local padded - padded=$(printf "%14s" "$raw") - printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded" + bashunit::str::lpad_to_slot 14 "$raw" + printf '%-40s %6s %6s %10s %12s\n' \ + "$name" "$revs" "$its" "$avg" "$_BASHUNIT_STR_LPAD_OUT" continue fi local raw="> ${max_ms}" - local padded - padded=$(printf "%12s" "$raw") + bashunit::str::lpad_to_slot 12 "$raw" printf '%-40s %6s %6s %10s %s%s%s\n' \ "$name" "$revs" "$its" "$avg" \ - "$_BASHUNIT_COLOR_FAILED" "$padded" "${_BASHUNIT_COLOR_DEFAULT}" + "$_BASHUNIT_COLOR_FAILED" "$_BASHUNIT_STR_LPAD_OUT" "${_BASHUNIT_COLOR_DEFAULT}" done bashunit::console_results::print_execution_time diff --git a/src/system/bash.sh b/src/system/bash.sh new file mode 100644 index 00000000..d2e8661d --- /dev/null +++ b/src/system/bash.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# What the running Bash can do, decided once at load time. +# +# bashunit floors at Bash 3.0, so it forks where a newer shell has a builtin. +# A flag here lets a leaf module ship two bodies for one helper and pick +# between them with a column-0 `if/else`, so a newer shell skips the fork and +# 3.0 keeps working. The floor does not move. +# +# Load time, not call time. Measured over 100k calls, best of 5, against a +# single ungated definition: a top-level `if/else` that picks the definition +# once costs between -0.07 and +0.09 microseconds per call -- nothing -- while +# a predicate function called per use costs +17.9 on Bash 3.0 and +4.3 on 5.3. +# +# The tier lives in the flag name so the compatibility rules can compare a +# construct's minimum version against a gate's declared tier by text alone; see +# adrs/adr-013-bash-version-gated-fast-paths.md for what a gate may and may not +# change, and tests/unit/project/bash_compatibility_test.sh for the enforcement. +# +# Compared major-then-minor rather than as a packed integer, so a two-digit +# minor (5.10) cannot read as a lower tier than 5.3. + +_BASHUNIT_BASH_GE_31=0 + +if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then + _BASHUNIT_BASH_GE_31=1 +elif [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ]; then + _BASHUNIT_BASH_GE_31=1 +fi diff --git a/src/system/index.sh b/src/system/index.sh index 28d65893..fffd2ba1 100644 --- a/src/system/index.sh +++ b/src/system/index.sh @@ -5,6 +5,7 @@ # # Capability probing: what this machine is and what it has. The bottom layer -- # nothing here touches config, test state, console or the runner. +source "$BASHUNIT_ROOT_DIR/src/system/bash.sh" source "$BASHUNIT_ROOT_DIR/src/system/check_os.sh" source "$BASHUNIT_ROOT_DIR/src/system/dependencies.sh" source "$BASHUNIT_ROOT_DIR/src/system/io.sh" diff --git a/src/util/str.sh b/src/util/str.sh index 9215f14c..9493bafb 100644 --- a/src/util/str.sh +++ b/src/util/str.sh @@ -16,6 +16,33 @@ function bashunit::random_str() { echo "$str" } +_BASHUNIT_STR_LPAD_OUT="" + +## +# Right-aligns $2 in a field of $1 characters, into _BASHUNIT_STR_LPAD_OUT. +# +# The first version-gated helper in the tree, and the shape every later one +# follows: a column-0 `if/else` on a flag from src/system/bash.sh, two bodies, +# one picked at load time. See adrs/adr-013-bash-version-gated-fast-paths.md. +# +# `printf -v` is Bash 3.1, and writing into a variable is the whole difference: +# the 3.0 body has to capture, which forks. Both bodies hand the same format +# and the same value to the same `printf`, so the padded result is identical +# by construction -- the only thing the gate changes is the fork. A gate that +# changed what came out would be a bug, not an optimisation. +# +# Arguments: $1 - the field width, $2 - the value to pad +## +if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + function bashunit::str::lpad_to_slot() { + printf -v _BASHUNIT_STR_LPAD_OUT "%${1}s" "$2" + } +else + function bashunit::str::lpad_to_slot() { + _BASHUNIT_STR_LPAD_OUT=$(printf "%${1}s" "$2") + } +fi + # Strip ANSI escape codes and control characters, writing the result into the # global slot _BASHUNIT_STR_STRIPPED_OUT (no fork on the plain-text fast path). # Callers on hot paths (assert_equals/assert_not_equals) use this to avoid the diff --git a/tests/unit/project/bash_compatibility_test.sh b/tests/unit/project/bash_compatibility_test.sh index a86874a9..757e7f3f 100644 --- a/tests/unit/project/bash_compatibility_test.sh +++ b/tests/unit/project/bash_compatibility_test.sh @@ -22,6 +22,79 @@ function bashunit::compat::offenders() { bashunit::compat::offenders_in "src/" "$1" } +# Every ":" sitting in the then-branch of a version gate whose tier +# is at least $2. +# +# A gate is a column-0 `if [ "$_BASHUNIT_BASH_GE_NN" = 1 ]; then`, closed by a +# column-0 `else` or `fi`. Column 0 is the whole check: an indented header is a +# runtime condition inside some other block, and gates nothing, so a construct +# under it would still reach a shell that cannot parse or run it. +# +# The tier is read from the flag name, which is why the name carries it. See +# adrs/adr-013-bash-version-gated-fast-paths.md. +function bashunit::compat::gated_lines_in() { + find "$1" -name '*.sh' -type f -print0 | + xargs -0 awk -v min="$2" ' + FNR == 1 { gate = 0 } + /^if \[ "\$_BASHUNIT_BASH_GE_[0-9]+" = 1 \]; then$/ { + tier = $0 + sub(/^.*_GE_/, "", tier) + sub(/".*$/, "", tier) + gate = tier + 0 + next + } + /^(else|fi)$/ { gate = 0; next } + gate > 0 && gate >= min { printf "%s:%d\n", FILENAME, FNR } + ' +} + +# Offenders for a pattern, minus the ones a sufficient gate covers. +# +# $3 is the construct's minimum Bash version as a tier: `printf -v` is 31, +# `declare -A` is 40. A tier of 0 means no gate is ever enough -- either the +# construct is a parse error below the floor, so it kills the file even in a +# branch that shell never takes, or its boundary is not established and +# guessing one would be worse than forbidding it. +# +# Matching stays with `grep -E`, and only the filtering is new: the patterns +# here are written for grep, and handing them to a second regex engine would +# quietly change which lines a rule catches. +function bashunit::compat::ungated_offenders_in() { + local dir=$1 + local pattern=$2 + local min=$3 + + local hits + hits="$(bashunit::compat::offenders_in "$dir" "$pattern")" + [ -n "$hits" ] || return 0 + + if [ "$min" -eq 0 ]; then + printf '%s\n' "$hits" + return 0 + fi + + local allowed + allowed=$'\n'"$(bashunit::compat::gated_lines_in "$dir" "$min")"$'\n' + + local line file lineno rest + while IFS= read -r line; do + [ -z "$line" ] && continue + file=${line%%:*} + rest=${line#*:} + lineno=${rest%%:*} + case "$allowed" in + *$'\n'"$file:$lineno"$'\n'*) continue ;; + esac + printf '%s\n' "$line" + done <> is Bash 4.0+. Use `>> file 2>&1` instead. function test_src_has_no_append_both_streams_redirect() { - assert_empty "$(bashunit::compat::offenders '&>>')" + assert_empty "$(bashunit::compat::ungated_offenders '&>>' 0)" } # 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')" + assert_empty "$(bashunit::compat::ungated_offenders 'BASHPID' 40)" } # 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:]_]|$)')" + assert_empty "$(bashunit::compat::ungated_offenders \ + '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)' 40)" } # 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:]]')" + assert_empty "$(bashunit::compat::ungated_offenders \ + '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*n)[[:space:]]' 43)" } # coproc is Bash 4.0+. function test_src_has_no_coproc() { - assert_empty "$(bashunit::compat::offenders '(^|[^[:alnum:]_])coproc([^[:alnum:]_]|$)')" + assert_empty "$(bashunit::compat::ungated_offenders '(^|[^[:alnum:]_])coproc([^[:alnum:]_]|$)' 40)" } # ${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]\}')" + assert_empty "$(bashunit::compat::ungated_offenders '\$\{[A-Za-z_][A-Za-z0-9_]*@[QEPAKa]\}' 44)" } # A temporary-environment locale prefix (`LC_ALL=C cmd`) makes bash change its @@ -234,5 +306,193 @@ function test_src_has_no_parameter_transformations() { function test_src_has_no_temporary_locale_assignment_prefix() { local pattern='(^|[;&|(])[[:space:]]*((LC_[A-Z_]+|LANG)=[^[:space:]]*[[:space:]]+)+[^[:space:]=]' - assert_empty "$(bashunit::compat::offenders "$pattern")" + assert_empty "$(bashunit::compat::ungated_offenders "$pattern" 0)" +} + + +# --- the gate mechanism itself -------------------------------------------- +# +# Everything above trusts bashunit::compat::ungated_offenders to allow a +# construct only where a sufficient gate covers it. These pin what "sufficient" +# means, on fixtures rather than on src/, because src/ is expected to be clean +# and a rule that quietly stopped matching would still look green. + +# Writes $2 into a fixture file under a fresh directory and echoes the directory. +function _gate_fixture() { # $1 = basename, $2 = contents + local dir + dir="$(bashunit::temp_dir)" + printf '%s\n' "$2" >"$dir/$1.sh" + printf '%s' "$dir" +} + +# `printf -v` is Bash 3.1, so tier 31 is exactly enough. +function test_a_construct_is_allowed_inside_a_gate_of_its_own_tier() { + local dir + dir="$(_gate_fixture gated 'if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + printf -v out "%s" "x" +fi')" + + assert_empty "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +function test_a_construct_is_allowed_inside_a_higher_gate() { + local dir + dir="$(_gate_fixture higher 'if [ "$_BASHUNIT_BASH_GE_50" = 1 ]; then + printf -v out "%s" "x" +fi')" + + assert_empty "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +function test_a_construct_is_rejected_when_it_is_not_gated_at_all() { + local dir + dir="$(_gate_fixture ungated 'printf -v out "%s" "x"')" + + assert_contains "ungated.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +function test_a_construct_is_rejected_under_a_lower_gate() { + local dir + dir="$(_gate_fixture too_low 'if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + declare -A map +fi')" + + assert_contains "too_low.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*A)' 40)" +} + +# The else-branch is what the OLD shell runs, so it is the one place a new +# construct is guaranteed to reach a shell that cannot handle it. +function test_a_construct_is_rejected_in_the_else_branch_of_a_gate() { + local dir + dir="$(_gate_fixture else_branch 'if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + out="x" +else + printf -v out "%s" "x" +fi')" + + assert_contains "else_branch.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +function test_a_construct_is_rejected_after_the_gate_closes() { + local dir + dir="$(_gate_fixture after_fi 'if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + out="x" +fi +printf -v out "%s" "x"')" + + assert_contains "after_fi.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +# An indented header is a runtime condition inside some other block. The body +# is parsed on every shell, and reached whenever that outer block runs, so it +# gates nothing. +function test_an_indented_gate_header_gates_nothing() { + local dir + dir="$(_gate_fixture indented 'function f() { + if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + printf -v out "%s" "x" + fi +}')" + + assert_contains "indented.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +# A header the recogniser does not accept must gate nothing, rather than being +# read loosely: a near-miss is how a rule quietly stops enforcing. +function test_a_non_canonical_gate_header_gates_nothing() { + local dir + dir="$(_gate_fixture non_canonical 'if [ "$_BASHUNIT_BASH_GE_31" = "1" ]; then + printf -v out "%s" "x" +fi')" + + assert_contains "non_canonical.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +# A pragma is the author's claim, not a structural fact, so honouring one would +# be a smuggling path by construction. It was considered and rejected. +function test_a_pragma_comment_cannot_smuggle_a_construct_in() { + local dir + dir="$(_gate_fixture pragma 'printf -v out "%s" "x" # bashunit: bash4')" + + assert_contains "pragma.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +# Tier 0 means no gate is ever enough: `arr+=(x)` and `&>>` are parse errors on +# the floor, so they kill the file from inside a branch that shell never takes. +function test_a_tier_zero_construct_is_rejected_even_inside_the_highest_gate() { + local dir + dir="$(_gate_fixture parse_time 'if [ "$_BASHUNIT_BASH_GE_50" = 1 ]; then + arr+=(x) + cmd &>>log +fi')" + + local pattern + pattern="$(bashunit::compat::append_prefix_pattern)"'\(' + + assert_contains "parse_time.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + "$pattern" 0)" + assert_contains "parse_time.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + '&>>' 0)" +} + +# An unclosed gate must not leak into the next file scanned. +function test_a_gate_does_not_carry_over_into_the_next_file() { + local dir + dir="$(bashunit::temp_dir)" + printf '%s\n' 'if [ "$_BASHUNIT_BASH_GE_50" = 1 ]; then + out="x"' >"$dir/a_unclosed.sh" + printf '%s\n' 'printf -v out "%s" "x"' >"$dir/b_next.sh" + + assert_contains "b_next.sh" "$(bashunit::compat::ungated_offenders_in "$dir" \ + 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" +} + +# Every gate in src/ names a flag the flags file actually declares. A typo +# would leave the flag empty, the fast body unreachable and the fallback in use +# on every shell -- green, silently slower, and impossible to see in review. +function test_every_gate_in_src_names_a_declared_flag() { + local used declared missing + used="$(grep -rhoE '_BASHUNIT_BASH_GE_[0-9]+' src/ | LC_ALL=C sort -u)" + declared="$(grep -hoE '^_BASHUNIT_BASH_GE_[0-9]+' src/system/bash.sh | LC_ALL=C sort -u)" + + missing="" + local flag + while IFS= read -r flag; do + [ -z "$flag" ] && continue + case "$declared" in + *"$flag"*) ;; + *) missing="$missing $flag" ;; + esac + done </dev/null || failures="$failures $file" + done < 2 { print $2, $3 }') + + assert_empty "$over_defined" +} diff --git a/tests/unit/util/str_test.sh b/tests/unit/util/str_test.sh index 9d91038b..ca932a5b 100644 --- a/tests/unit/util/str_test.sh +++ b/tests/unit/util/str_test.sh @@ -209,3 +209,66 @@ function test_rpad_to_slot_matches_rpad_when_truncating() { function test_rpad_to_slot_matches_rpad_when_width_is_smaller_than_right_word() { _rpad_slot_matches_capture "input" "right-text" 3 } + +# --- the version-gated lpad helper ---------------------------------------- +# +# The first gated helper in the tree (#1352). A gate may change speed and must +# never change what comes out, so these check both halves: that the running +# shell got the body its tier says it should, and that the body agrees with the +# `printf` it replaced over every shape the callers use. + +function test_the_bash_31_flag_matches_the_running_shell() { + local expected=0 + if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then + expected=1 + elif [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ]; then + expected=1 + fi + + assert_same "$expected" "$_BASHUNIT_BASH_GE_31" +} + +# A gate that silently always falls back would pass every equivalence test +# while delivering none of the speed, so the selected body is asserted too. +# Introspected with `type`, not `declare -f`: real Bash 3.0 refuses a `::` name +# there. +function test_lpad_selects_the_body_its_tier_calls_for() { + local body + body="$(type bashunit::str::lpad_to_slot 2>/dev/null)" + + if [ "$_BASHUNIT_BASH_GE_31" = 1 ]; then + assert_contains "printf -v" "$body" + else + assert_not_contains "printf -v" "$body" + fi +} + +function test_lpad_matches_the_printf_it_replaced() { + local shape width value expected + local mismatches="" + + for shape in "14:≤ 5" "12:> 5" "6:12.5" "3:abcdef" "1:x" "8:" "4: "; do + width=${shape%%:*} + value=${shape#*:} + expected="$(printf "%${width}s" "$value")" + bashunit::str::lpad_to_slot "$width" "$value" + if [ "$expected" != "$_BASHUNIT_STR_LPAD_OUT" ]; then + mismatches="$mismatches [$width|$value]" + fi + done + + assert_empty "$mismatches" +} + +function test_lpad_right_aligns_within_the_field() { + bashunit::str::lpad_to_slot 5 "ab" + + assert_same " ab" "$_BASHUNIT_STR_LPAD_OUT" +} + +# A value longer than the field is printed whole, the way printf does it. +function test_lpad_does_not_truncate_a_value_wider_than_the_field() { + bashunit::str::lpad_to_slot 2 "abcdef" + + assert_same "abcdef" "$_BASHUNIT_STR_LPAD_OUT" +}