Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions TextFilter.Test/TextFilter.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
<IsTestProject>true</IsTestProject>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<!--
ktsu.Sdk defaults this to true, which resolves every culture to the invariant one and makes
new CultureInfo("tr-TR") throw. That default belongs to the application being built, not to
the applications that consume this library: a consumer outside the ktsu SDK runs with real
culture data, and culture-sensitive matching is only observable there. The test host needs
the culture data its callers have, or the tests that pin culture independence cannot run.
-->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>

<ItemGroup>
Expand Down
56 changes: 56 additions & 0 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down
8 changes: 7 additions & 1 deletion TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1);

[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Not available in older frameworks")]
private static Regex RegexMatchAnything() => new(".*", RegexOptions.Compiled);

Check warning on line 101 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 101 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 101 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 101 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

/// <summary>
/// Gets a hint for the specified filter type.
Expand Down Expand Up @@ -250,7 +250,7 @@

return ExcludedTokenPrefixes.Contains(prefix)
? TextFilterTokenType.Excluded
: RequiredTokenPrefixes.Contains(prefix)

Check warning on line 253 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 253 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 253 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 253 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 253 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 253 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
? TextFilterTokenType.Required
: TextFilterTokenType.Optional;
})
Expand Down Expand Up @@ -408,8 +408,14 @@
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
Expand Down
Loading