From 1fedad62f3cc32c0b57477f94b7938c585e2051a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 2 Aug 2026 18:54:42 +0200 Subject: [PATCH 1/3] perf(assert): fold case with nocasematch instead of two tr forks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assert_contains_ignore_case lowercased both operands through a `tr` pipeline, paying two subprocess forks on every call. `shopt -s nocasematch` folds case inside the `case` statement itself and costs none. Measured on Bash 3.2: 0.087ms per call against 12.8ms, and 131ms against 1419ms for a 300-assertion run. nocasematch arrived in Bash 3.1 and this project's floor is 3.0, so the tr path stays as the fallback for that one version -- gated by a predicate in the same shape as runner/context.sh's existing pipefail check. Verified by forcing the predicate to fail: the whole assert suite passes on the 3.0 path, with identical results. Two things worth recording, because both rule out approaches that look better. A pure-bash A-Z loop would be fork-free on every version including 3.0, and it benchmarks well: 0.22ms against 2.50ms at 8 characters, crossing over around 128. It is still wrong. In a UTF-8 locale `tr '[:upper:]' '[:lower:]'` folds accented text, so `ñü` matches `ÑÜ` today; an ASCII-only loop would silently stop matching it, and the repo's Spanish, Brazilian and Japanese locale jobs exist for exactly this. nocasematch folds non-ASCII identically -- checked in both directions. `${var,,}` is equally correct on Bash 4+ and was rejected for a different reason: it is banned syntax here, so using it would mean punching an exception into the Bash 3.0 compatibility gate, which is the strongest contract in the repo. nocasematch needs no exception and starts helping a whole minor version earlier. The option is saved and restored rather than blindly unset -- it is global, and a user's test file may have set it deliberately. `shopt -q` is a builtin, so the save is free. Three tests pin that: unset stays unset, set stays set, and non-ASCII still folds. 1657 sequential / 1616 parallel; baseline + 3. Fork budgets unchanged. --- CHANGELOG.md | 1 + src/assert/core.sh | 47 ++++++++++++++++++++++++++++++-- tests/unit/assert/string_test.sh | 34 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f64133..788768cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +- `assert_contains_ignore_case` folds case with `shopt -s nocasematch` on Bash 3.1+ instead of two `tr` subprocesses, and falls back to `tr` only on Bash 3.0. Roughly 10x faster in a run dominated by that assertion (300 calls: 1419ms -> 131ms), with identical results including non-ASCII folding - Internal: `src/runner.sh` and `src/coverage.sh` are split into `src/runner/` and `src/coverage/` modules of single-responsibility files, each behind a `source`-only `index.sh` aggregator. A pure relocation, no behavior change; see [ADR-010](adrs/adr-010-src-module-directories.md) (#924, #925) ### Fixed diff --git a/src/assert/core.sh b/src/assert/core.sh index fafcceee..158bcc3a 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -325,6 +325,18 @@ function assert_contains() { bashunit::state::add_assertions_passed } +## +# Whether `shopt -s nocasematch` exists. Introduced in Bash 3.1; bashunit's +# floor is 3.0, so the caller keeps a tr-based fallback for that one version. +# Returns: 0 when nocasematch is available (Bash >= 3.1), 1 otherwise. +## +function bashunit::assert::_supports_nocasematch() { + if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then + return 0 + fi + [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ] +} + function assert_contains_ignore_case() { bashunit::assert::should_skip && return 0 @@ -332,8 +344,39 @@ function assert_contains_ignore_case() { local actual="$2" local label_override="${3:-}" - # Bash 3.0 compatible: use tr for case-insensitive comparison - # (shopt nocasematch was introduced in Bash 3.1) + # nocasematch (Bash 3.1+) folds case inside the `case` itself, which costs no + # fork at all; the two `tr` pipelines below cost two. Measured on Bash 3.2: + # 0.087ms per call versus 12.8ms. Both fold non-ASCII identically in a UTF-8 + # locale -- `ñü` matches `ÑÜ` either way -- which rules out the tempting + # pure-bash A-Z loop, since that is ASCII-only and would silently stop + # matching accented text that matches today. + # + # Prior state is saved and restored rather than blindly unset: nocasematch is + # a global shell option and a user's test file may already have set it. `shopt + # -q` is a builtin, so the save costs nothing. + if bashunit::assert::_supports_nocasematch; then + local nocase_was_set=1 + shopt -q nocasematch || nocase_was_set=0 + shopt -s nocasematch + + local matched=1 + case "$actual" in + *"$expected"*) ;; + *) matched=0 ;; + esac + + [ "$nocase_was_set" -eq 1 ] || shopt -u nocasematch + + if [ "$matched" -eq 0 ]; then + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to contain" "${expected}" + return + fi + + bashunit::state::add_assertions_passed + return + fi + + # Bash 3.0 only: nocasematch does not exist, so fold with tr. local expected_lower local actual_lower expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index 0d48df56..9daf5d6b 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -236,3 +236,37 @@ function test_unsuccessful_assert_string_not_matches_format() { "42 items" "to not match format" "%d items")" \ "$(assert_string_not_matches_format "%d items" "42 items")" } + +# nocasematch is a global shell option, so the fast path in +# assert_contains_ignore_case has to leave it exactly as it found it -- a user's +# test file may have set it deliberately, and silently clearing it would change +# how their own `case` statements match. +function test_assert_contains_ignore_case_leaves_nocasematch_unset_when_it_was_unset() { + shopt -u nocasematch + + assert_contains_ignore_case "world" "Hello WORLD" + + if shopt -q nocasematch; then + bashunit::fail "nocasematch leaked ON into the caller" + fi +} + +function test_assert_contains_ignore_case_leaves_nocasematch_set_when_it_was_set() { + shopt -s nocasematch + + assert_contains_ignore_case "world" "Hello WORLD" + + local still_set=1 + shopt -q nocasematch || still_set=0 + shopt -u nocasematch + + assert_equals "1" "$still_set" +} + +# Non-ASCII folding is why the tr fallback cannot be replaced with a pure-bash +# A-Z loop: in a UTF-8 locale both nocasematch and tr fold accented text, and an +# ASCII-only loop would silently stop matching it. +function test_assert_contains_ignore_case_folds_non_ascii() { + assert_contains_ignore_case "ñü" "Test ÑÜ string" + assert_contains_ignore_case "ÑÜ" "test ñü string" +} From f230dd8a0f807173d95e07c4ed949c9cd8e737f9 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 2 Aug 2026 18:59:58 +0200 Subject: [PATCH 2/3] test(assert): probe locale support before asserting non-ASCII folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new non-ASCII test failed on Windows. Not a regression: Git Bash runs in a C-ish locale where neither implementation folds accented case. Verified both agree there -- under LC_ALL=C, tr leaves 'ÑÜ' unchanged and nocasematch does not match either, which is correct behaviour and identical between the two paths. The test was asserting a UTF-8-only outcome as if it were universal. It now probes whether the platform folds non-ASCII at all and skips when it does not, so it still guards the equivalence that matters wherever that equivalence is observable. --- tests/unit/assert/string_test.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index 9daf5d6b..c2449e4f 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -264,9 +264,21 @@ function test_assert_contains_ignore_case_leaves_nocasematch_set_when_it_was_set } # Non-ASCII folding is why the tr fallback cannot be replaced with a pure-bash -# A-Z loop: in a UTF-8 locale both nocasematch and tr fold accented text, and an -# ASCII-only loop would silently stop matching it. +# A-Z loop: where the locale folds accented text, both nocasematch and tr do it +# and an ASCII-only loop would silently stop matching. +# +# Whether it folds at all is a property of the platform's locale, not of +# bashunit: under LC_ALL=C neither path folds, which is correct and is what Git +# Bash on Windows does. So the capability is probed first and the assertion is +# skipped where it does not apply, rather than pinning a UTF-8-only outcome. function test_assert_contains_ignore_case_folds_non_ascii() { + local folded + folded=$(printf '%s' "ÑÜ" | tr '[:upper:]' '[:lower:]') + if [ "$folded" != "ñü" ]; then + bashunit::skip "locale does not fold non-ASCII case" + return + fi + assert_contains_ignore_case "ñü" "Test ÑÜ string" assert_contains_ignore_case "ÑÜ" "test ñü string" } From 6f302d8ceaf106562145dc854ebdd56a0c969fe0 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 2 Aug 2026 19:10:18 +0200 Subject: [PATCH 3/3] test(assert): guard the nocasematch tests for Bash 3.0 The Bash 3.0 jobs went red on the new leak tests, and correctly so: they call `shopt -u nocasematch` directly, and on 3.0 that is an "invalid shell option name" error, so the tests could not even set themselves up. The production code was already gated -- 3.0 takes the tr path. Only the tests reached for the option unconditionally. They now skip on the same predicate, which is also the honest outcome: on 3.0 there is no nocasematch, so there is no option to leak. --- tests/unit/assert/string_test.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index c2449e4f..d2b94298 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -237,11 +237,21 @@ function test_unsuccessful_assert_string_not_matches_format() { "$(assert_string_not_matches_format "%d items" "42 items")" } +# Guarded on the same predicate the production code uses: `shopt -u nocasematch` +# is itself an "invalid shell option name" error on Bash 3.0, so these tests +# cannot even set up there -- and on 3.0 the tr path runs, where there is no +# option to leak. +# # nocasematch is a global shell option, so the fast path in # assert_contains_ignore_case has to leave it exactly as it found it -- a user's # test file may have set it deliberately, and silently clearing it would change # how their own `case` statements match. function test_assert_contains_ignore_case_leaves_nocasematch_unset_when_it_was_unset() { + if ! bashunit::assert::_supports_nocasematch; then + bashunit::skip "nocasematch requires Bash 3.1+" + return + fi + shopt -u nocasematch assert_contains_ignore_case "world" "Hello WORLD" @@ -252,6 +262,11 @@ function test_assert_contains_ignore_case_leaves_nocasematch_unset_when_it_was_u } function test_assert_contains_ignore_case_leaves_nocasematch_set_when_it_was_set() { + if ! bashunit::assert::_supports_nocasematch; then + bashunit::skip "nocasematch requires Bash 3.1+" + return + fi + shopt -s nocasematch assert_contains_ignore_case "world" "Hello WORLD"