From 2ac52ad95a27a26d0c2b112616d4dcb72f643301 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 9 Sep 2026 11:46:39 +0200 Subject: [PATCH] fix(assert): say what assert_contains_ignore_case actually folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment claimed the two implementations fold non-ASCII identically in a UTF-8 locale, and used that to rule out a pure-bash A-Z fold for the Bash 3.0 fallback. Measured on `ÑÜ`: BSD tr (macOS) folds -> ñü GNU tr (Debian) does not -> ÑÜ busybox tr does not -> ÑÜ nocasematch, UTF-8 folds -> matches nocasematch, LC_ALL=C does not -> no match So the branches disagree wherever tr is not BSD, and the fast path disagrees with itself across locales. The Bash 3.0 job runs on Debian, so the difference is in CI, not hypothetical. ASCII folding is the only portable guarantee, so that is what the assertion promises now: stated in the docs, in the comment with the measurements, and pinned by a test that runs on both implementations -- the Bash 3.0 job takes the tr branch, every other job the fast one. Non-ASCII is deliberately left unasserted rather than pinned to whichever platform the test happens to run on. No behaviour change. The wider point is worth keeping: `tr` is not one implementation, and any claim that a pure-bash construct and an external tool agree needs BSD, GNU and busybox, not just a developer's Mac. Closes #1351 Claude-Session: https://claude.ai/code/session_01EXYWTGLjf7qM8Ru3GakDRm --- CHANGELOG.md | 1 + docs/assertions.md | 2 ++ src/assert/core.sh | 23 +++++++++++++++---- ...it_should_display_all_assert_docs.snapshot | 2 ++ tests/unit/assert/string_test.sh | 13 +++++++++++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70728ba8..b3fec0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Performance: a test in a file that defines `set_up` or `tear_down` is about 2.6x faster, which brings it level with a hookless test (14.1ms to 5.4ms per test on macOS arm64, bash 3.2). Each hook minted its output file with `mktemp` and removed it with `rm`, and the ownership marker that left behind made the runner `rm -rf` the test's temp files at exit — five forks per test, even for a test that created no temp file of its own (#1345) ### Fixed +- `assert_contains_ignore_case` documents what it actually guarantees: ASCII case folding. Whether `ñ` matches `Ñ` depends on the shell version, the locale and which `tr` the system ships — BSD `tr` folds it, GNU and busybox `tr` do not, and `nocasematch` only folds it in a UTF-8 locale. Behaviour is unchanged (#1351) - A test function whose name another selected name starts with is no longer rejected as a duplicate. `test_a` alongside `test_ab` read as one name twice, which made the whole file select nothing. Only definition order reached it, so nothing hit it before (#1347) - Coverage counts every line of a multiline statement: array literals, quoted strings, heredocs, backslash continuations. An array written one element per line cost one uncovered line per element. Bash 3.x records the assignment on its closing `)`, so there the whole array read as uncovered. A parent statement's hits no longer mark commands inside command or process substitutions as covered, even within quotes or arrays (#1338) - `--exclude-filter` keeps a comma as part of the test function name. The value was split at the comma, so `--exclude-filter 'test_a,{b}'` also excluded `test_a`. Repeat the flag to exclude several names. `BASHUNIT_EXCLUDE_FILTER` stays comma-separated, so one of its filters cannot hold a literal comma (#1340) diff --git a/docs/assertions.md b/docs/assertions.md index b9ff3228..e93e4fec 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -200,6 +200,8 @@ function test_failure() { Reports an error if `needle` is not a substring of `haystack`. Differences in casing are ignored when needle is searched for in haystack. +Case folding is guaranteed for ASCII only: whether `ñ` matches `Ñ` depends on +the shell version, the locale and which `tr` the system ships. ::: code-group ```bash [Example] diff --git a/src/assert/core.sh b/src/assert/core.sh index d175a72a..3f2c3951 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -462,10 +462,22 @@ function assert_contains_ignore_case() { # 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. + # 0.087ms per call versus 12.8ms. + # + # Only ASCII folding is promised, because only ASCII folding is portable. + # Measured on `ÑÜ`: + # + # BSD tr (macOS) folds -> ñü + # GNU tr (Debian) does not -> ÑÜ + # busybox tr does not -> ÑÜ + # nocasematch, UTF-8 folds -> matches + # nocasematch, LC_ALL=C does not -> no match + # + # So the two branches disagree on non-ASCII wherever `tr` is not BSD, and the + # fast path alone disagrees with itself across locales. The Bash 3.0 job runs + # on Debian, so that is CI, not a hypothetical. This comment used to claim the + # two agreed on non-ASCII in a UTF-8 locale, and used that to rule out a + # pure-bash A-Z fold for the fallback; the claim was wrong (#1351). # # 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 @@ -492,7 +504,8 @@ function assert_contains_ignore_case() { return fi - # Bash 3.0 only: nocasematch does not exist, so fold with tr. + # Bash 3.0 only: nocasematch does not exist, so fold with tr. ASCII-only in + # practice on GNU and busybox tr; see the note above. local expected_lower local actual_lower expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index adaacac3..6504b02f 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -53,6 +53,8 @@ Reports an error if `needle` is not a substring of `haystack`. Reports an error if `needle` is not a substring of `haystack`. Differences in casing are ignored when needle is searched for in haystack. +Case folding is guaranteed for ASCII only: whether `ñ` matches `Ñ` depends on +the shell version, the locale and which `tr` the system ships. ## assert_empty diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index d2b94298..03f816af 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -53,6 +53,19 @@ function test_successful_assert_contains_ignore_case() { assert_empty "$(assert_contains_ignore_case "Linux" "GNU/LINUX")" } +# ASCII case folding is the whole of what this assertion promises, and it has +# two implementations -- nocasematch from Bash 3.1, two `tr` pipelines below +# that. This runs on both: the Bash 3.0 job takes the `tr` branch, every other +# job the fast one. Non-ASCII is deliberately not asserted, because BSD `tr` +# folds it, GNU and busybox `tr` do not, and nocasematch itself only folds it +# in a UTF-8 locale (#1351). +function test_assert_contains_ignore_case_folds_ascii_on_either_implementation() { + assert_empty "$(assert_contains_ignore_case "linux" "GNU/LINUX")" + assert_empty "$(assert_contains_ignore_case "LINUX" "gnu/linux")" + assert_empty "$(assert_contains_ignore_case "LiNuX" "GnU/lInUx")" + assert_empty "$(assert_contains_ignore_case "0-9_." "0-9_.")" +} + function test_unsuccessful_assert_contains_ignore_case() { local expected expected="$(bashunit::console_results::print_failed_test \