From 65c85540c40497035e84989d35f7ce2442de38c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:35:57 +0000 Subject: [PATCH] Fold case invariantly in regex matching [patch] 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 Claude-Session: https://claude.ai/code/session_012betVHk3gFcj5RYkEe4vrm --- README.md | 6 +++ TextFilter.Test/TextFilter.Test.csproj | 8 ++++ TextFilter.Test/TextFilterTests.cs | 56 ++++++++++++++++++++++++++ TextFilter/TextFilter.cs | 8 +++- 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6eec78b..8509291 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,12 @@ tokens as well as optional ones. `TextFilterType.Fuzzy` does not take the setting: fuzzy matching is always case insensitive. +Case is folded invariantly, so the answer does not depend on the calling thread's `CurrentCulture`. +That matters for the dotted and dotless I: under a Turkish culture, `i` and `I` are not each other's +case pair, and a culture-sensitive fold would stop a filter of `img` matching `IMG_1234.JPG` on a +machine whose locale happens to be `tr-TR`. A filter pattern is machine text rather than prose, so +it folds the same way everywhere. + ### Filter Types TextFilter supports different filter types: diff --git a/TextFilter.Test/TextFilter.Test.csproj b/TextFilter.Test/TextFilter.Test.csproj index 135f735..87f0f7d 100644 --- a/TextFilter.Test/TextFilter.Test.csproj +++ b/TextFilter.Test/TextFilter.Test.csproj @@ -6,6 +6,14 @@ true net10.0 + + false diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 310d096..397a5fc 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -3,6 +3,7 @@ namespace TextFilter.Test; using System.Collections.Generic; +using System.Globalization; using System.Linq; using ktsu.TextFilter; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -607,6 +608,61 @@ public void TheTwoSensitivitiesDoNotCollideInTheGlobCache() Assert.IsFalse(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); } + [TestMethod] + public void RegexCaseInsensitivityDoesNotDependOnTheCurrentCulture() + { + // Turkish folds "i" to "İ" and "I" to "ı", so IgnoreCase without CultureInvariant stops + // treating "i" and "I" as the same letter. Only a pattern containing that letter shows it: + // ".*\.jpg" matches "IMG_1234.JPG" across case under tr-TR perfectly well, which is why the + // case-insensitivity tests above never caught this. + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + + // The premise, stated rather than assumed: under globalization-invariant mode this + // culture request silently resolves to the invariant culture, and the assertion below + // would then pass without ever exercising the fold it is about. + if (CultureInfo.CurrentCulture.TextInfo.ToUpper("i") == "I") + { + Assert.Inconclusive("This runtime does not apply Turkish case mapping, so the regression cannot be provoked here."); + } + + Assert.IsTrue( + TextFilter.IsMatch("IMG_5678.JPG", "img", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive), + "A case-insensitive filter should fold i and I whatever locale the calling thread runs under."); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [TestMethod] + public void ARegexCompiledUnderOneCultureAnswersTheSameUnderAnother() + { + // The cache is keyed by pattern and sensitivity, not by culture, so a culture-dependent fold + // is not merely wrong under tr-TR -- it leaks. Whichever culture compiled the pattern first + // decides the answer for every later caller on every thread. A pattern used nowhere else, so + // this owns its cache entry and the ordering below is the one that actually runs. + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + bool underTurkish = TextFilter.IsMatch("MINI.TXT", "mini", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + bool underInvariant = TextFilter.IsMatch("MINI.TXT", "mini", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + + Assert.IsTrue(underTurkish, "The first caller's culture should not decide the answer."); + Assert.AreEqual(underTurkish, underInvariant, "A cached pattern should answer the same for every caller."); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + [TestMethod] public void TheTwoSensitivitiesDoNotCollideInTheRegexCache() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 1cf1c68..df64d37 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -408,8 +408,14 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt string cacheKey = CacheKey(filter, caseSensitivity); if (!RegexCache.TryGetValue(cacheKey, out Regex? regex)) { + // CultureInvariant, because IgnoreCase alone folds case using the thread's CurrentCulture. + // Under tr-TR that stops "i" and "I" being the same letter, so a filter of "img" no longer + // matches "IMG_1234.JPG" -- and the cache below is keyed by pattern and sensitivity only, + // so whichever culture happened to compile the pattern first decides the answer for every + // later caller on any thread. A filter pattern is machine text, not prose, and the glob + // path already folds invariantly, so the two paths now agree. RegexOptions regexOptions = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive - ? RegexOptions.Compiled | RegexOptions.IgnoreCase + ? RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant : RegexOptions.Compiled; try