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