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