Skip to content

Fold case invariantly in regex matching - #104

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/exciting-albattani-fru842-102
Sep 26, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/exciting-albattani-fru842-102

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #102

The defect

RegexOptions.IgnoreCase without RegexOptions.CultureInvariant folds case using the calling thread's CurrentCulture. Under tr-TR, i and I are not each other's case pair — i uppercases to İ and I lowercases to ı.

Measured on .NET 10.0.401, Linux:

expression invariant tr-TR
new Regex("i", IgnoreCase).IsMatch("I") True False
new Regex("I", IgnoreCase).IsMatch("i") True False
new Regex("img", IgnoreCase).IsMatch("IMG_1234.JPG") True False
new Regex("i", IgnoreCase | CultureInvariant).IsMatch("I") True True
new Regex(@".*\.jpg", IgnoreCase).IsMatch("IMG_1234.JPG") True True

That last row is why this was never caught. The existing RegexMatchesAcrossCaseWhenCaseInsensitiveIsRequested uses .*\.jpg, and j, p and g fold identically in every culture. Only a pattern containing i or I shows the defect.

The cache makes it worse than a Turkish-locale bug. RegexCache is keyed by CacheKey(filter, caseSensitivity) — pattern and sensitivity, not culture. A pattern compiled once is reused for every later caller on every thread, so whichever culture happened to get there first decides the answer for the whole process. A single request served on a thread with a Turkish culture poisons the entry for everyone.

The change

One option added at TextFilter.cs:412, as the issue suggests:

? RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant

This brings the regex path into line with the glob path, which DotNet.Glob already folds invariantly — so the two filter types now agree rather than disagreeing on a locale.

Why nobody noticed: the test project could not reproduce it

This is the part worth reading before reviewing the diff.

ktsu.Sdk/Sdk.props:738 sets <InvariantGlobalization>true</InvariantGlobalization> for every project built with the SDK. Under that setting every culture request resolves to the invariant culture and new CultureInfo("tr-TR") throws CultureNotFoundException outright. A test written against the tr-TR fold does not fail — it errors, and no test written the obvious way could ever have caught this.

So TextFilter.Test.csproj now sets <InvariantGlobalization>false</InvariantGlobalization>, with a comment explaining why.

That is a deliberate judgement and the one point in this change I would push back on myself, so the reasoning in full: the SDK default is right for an application, which controls its own runtime and can decide it needs no culture data. It is wrong for the test host of a library, because the library's consumers are not necessarily built with ktsu.Sdk, and a consumer with real culture data is precisely the caller this bug afflicts. The test project's job is to stand in for those callers, which it cannot do while stripped of the culture data they have.

Nothing about the shipping library changes — InvariantGlobalization is a runtime host setting, it affects only TextFilter.Test's own test host, and TextFilter.csproj is untouched. The whole existing suite passes identically under it (84 before, 84 after, plus the 2 new), so turning real culture data on did not perturb any other test.

Tests

test covers
RegexCaseInsensitivityDoesNotDependOnTheCurrentCulture the defect — img against IMG_5678.JPG under tr-TR
ARegexCompiledUnderOneCultureAnswersTheSameUnderAnother the cache leak — the same pattern queried under tr-TR then invariant must agree

Both use patterns used nowhere else in the suite, so each owns its cache entry and the ordering the test asserts on is the ordering that actually runs. Both restore CurrentCulture in a finally.

The first carries an Assert.Inconclusive guard on CultureInfo.CurrentCulture.TextInfo.ToUpper("i") == "I". If someone later re-enables invariant globalization on the test project, the test says so out loud instead of passing for the wrong reason. (The guard is written without a Regex because this repo runs analyzers as errors and SYSLIB1045 demands [GeneratedRegex] for any inline pattern.)

Proved failing without the fix. Reverting only TextFilter/TextFilter.cs and keeping everything else:

failed RegexCaseInsensitivityDoesNotDependOnTheCurrentCulture (27ms)
  Assertion failed. Expected condition to be true.
  A case-insensitive filter should fold i and I whatever locale the calling thread runs under.
  actual: false
failed ARegexCompiledUnderOneCultureAnswersTheSameUnderAnother (61ms)
  total: 86   failed: 2   succeeded: 84

Both fail on the fold itself, not on a message that merely changed shape.

Verification

  • dotnet build TextFilter.sln -c Release — succeeded, 0 warnings, 0 errors (analyzers run as errors here)
  • dotnet test TextFilter.sln -c Release — 86 total, 86 passed, 0 failed, 0 skipped
  • Same suite against reverted TextFilter.cs — 2 of 86 failed, as above

Docs

README.md's "Case Sensitivity" section gains a paragraph stating that case folds invariantly and naming the dotted/dotless I as the reason. The issue notes that neither the README nor the tests mentioned culture, which is what made this look like settled behaviour rather than an oversight.

Not in this change

ktsu-dev/TextFilter#103 — RegexCache and GlobCache growing unbounded for the process lifetime — is untouched. It is about the same two caches and is genuinely adjacent, but it is an eviction-policy decision rather than a correctness fix, and folding it in here would put a bounded-cache design in a one-option bug fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_012betVHk3gFcj5RYkEe4vrm


Generated by Claude Code

RegexOptions.IgnoreCase without RegexOptions.CultureInvariant folds case
using the calling thread's CurrentCulture. Under tr-TR that stops "i" and
"I" being the same letter, so a filter of "img" no longer matched
"IMG_1234.JPG".

The regex cache is keyed by pattern and sensitivity, not by culture, so the
fault was not confined to Turkish callers: whichever culture compiled a
pattern first decided the answer for every later caller on every thread.

The glob path already folds invariantly, so the two paths now agree.

The test project opts out of the InvariantGlobalization default ktsu.Sdk
sets, which is what kept this invisible: with every culture resolving to the
invariant one, new CultureInfo("tr-TR") throws and the fold under test can
never happen. That default belongs to the application being built, not to
the applications that consume this library.

Fixes #102

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012betVHk3gFcj5RYkEe4vrm
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 54f3b70 into main Sep 26, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/exciting-albattani-fru842-102 branch September 26, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Case-insensitive regex matching gives culture-dependent results (Turkish "İ/i" breaks IgnoreCase)

2 participants