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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ ktsu.TextFilter is a .NET library that provides methods for filtering text based
- **Regular Expression Matching**: Filter text using regular expressions.
- **Fuzzy Matching**: Rank text based on how well it matches a fuzzy pattern.
- **Customizable Match Options**: Match by whole string, all words, or any word.
- **Case Sensitivity**: Opt into case-insensitive glob and regex matching; case sensitive by default.

## Installation

Expand Down Expand Up @@ -98,6 +99,30 @@ bool allWordsMatch = TextFilter.Match(text, pattern, MatchOptions.AllWords);
bool wholeStringMatch = TextFilter.Match(text, pattern, MatchOptions.WholeString);
```

### Case Sensitivity

Glob and regular expression matching are **case sensitive by default**. Pass
`TextFilterCaseSensitivity.CaseInsensitive` to fold case on both the text and the pattern:

```csharp
using ktsu.TextFilter;

// Case sensitive (the default) - a camera writes IMG_1234.JPG, so this does not match
bool sensitive = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg",
TextFilterType.Glob, TextFilterMatchOptions.ByWholeString); // false

// Case insensitive
bool insensitive = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg",
TextFilterType.Glob, TextFilterMatchOptions.ByWholeString,
TextFilterCaseSensitivity.CaseInsensitive); // true
```

The setting is available on `IsMatch`, `Filter`, `DoesMatchGlob`, `DoesMatchRegex`,
`AnyTokenMatchesGlobFilter` and `AllTokensMatchGlobFilter`, and applies to required and excluded
tokens as well as optional ones.

`TextFilterType.Fuzzy` does not take the setting: fuzzy matching is always case insensitive.

### Filter Types

TextFilter supports different filter types:
Expand Down Expand Up @@ -151,6 +176,13 @@ The primary class for text filtering operations.
| `Regex` | Use regular expression matching |
| `Fuzzy` | Use fuzzy matching |

#### `TextFilterCaseSensitivity`

| Value | Description |
|-------|-------------|
| `CaseSensitive` | Uppercase and lowercase are distinct (the default) |
| `CaseInsensitive` | Uppercase and lowercase are equivalent, for glob and regex matching |

## Contributing

Contributions are welcome! For feature requests, bug reports, or questions, please open an issue on GitHub. If you would like to contribute code, please open a pull request with your changes.
Expand Down
118 changes: 118 additions & 0 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -548,4 +548,122 @@ public void DoesMatchGlobHandlesPartialFilter()
bool result = TextFilter.DoesMatchGlob("hello world", "-", TextFilterMatchOptions.ByWordAll);
Assert.IsTrue(result, "Partial filter with only '-' should return true.");
}

// ---- Case sensitivity (issue #97) ----

[TestMethod]
public void GlobIsCaseSensitiveByDefault()
{
bool result = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString);
Assert.IsFalse(result, "Glob matching should remain case sensitive when no sensitivity is requested.");
}

[TestMethod]
public void GlobMatchesAcrossCaseWhenCaseInsensitiveIsRequested()
{
bool result = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive);
Assert.IsTrue(result, "'*.jpg' should match 'IMG_1234.JPG' when case insensitive matching is requested.");
}

[TestMethod]
public void GlobCaseInsensitivityAppliesToTheFilterAsWellAsTheText()
{
bool result = TextFilter.IsMatch("photo.png", "*.PNG", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive);
Assert.IsTrue(result, "An uppercase filter should match lowercase text when case insensitive matching is requested.");
}

[TestMethod]
public void GlobCaseInsensitivityDoesNotMatchUnrelatedText()
{
bool result = TextFilter.IsMatch("IMG_1234.PNG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive);
Assert.IsFalse(result, "Case insensitivity should fold case only, not widen the match to a different extension.");
}

[TestMethod]
public void RegexIsCaseSensitiveByDefault()
{
bool result = TextFilter.IsMatch("IMG_1234.JPG", @".*\.jpg", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString);
Assert.IsFalse(result, "Regex matching should remain case sensitive when no sensitivity is requested.");
}

[TestMethod]
public void RegexMatchesAcrossCaseWhenCaseInsensitiveIsRequested()
{
bool result = TextFilter.IsMatch("IMG_1234.JPG", @".*\.jpg", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive);
Assert.IsTrue(result, "The sensitivity setting should reach the regex path, not only the glob path.");
}

[TestMethod]
public void TheTwoSensitivitiesDoNotCollideInTheGlobCache()
{
// Both caches are keyed by pattern text. Without the sensitivity in the key, whichever of
// these ran first would decide the answer for the other, in whichever order they ran.
Assert.IsFalse(TextFilter.IsMatch("A.TXT", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
Assert.IsTrue(TextFilter.IsMatch("A.TXT", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));

// Same check with the insensitive variant cached first, on a pattern used nowhere else, so
// the isolation holds in both orders rather than only the one the pair above happens to take.
Assert.IsTrue(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
Assert.IsFalse(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
}

[TestMethod]
public void TheTwoSensitivitiesDoNotCollideInTheRegexCache()
{
Assert.IsFalse(TextFilter.IsMatch("C.TXT", @".*\.txt", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
Assert.IsTrue(TextFilter.IsMatch("C.TXT", @".*\.txt", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
}

[TestMethod]
public void CaseInsensitivityReachesRequiredAndExcludedTokens()
{
// Required and excluded tokens go through their own call sites, so they need their own guard:
// threading the sensitivity into the optional branch alone would leave these two behind.
Assert.IsTrue(TextFilter.IsMatch("READ ME", "+read", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive),
"A required token should honour case insensitivity.");
Assert.IsFalse(TextFilter.IsMatch("READ ME", "-read", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive),
"An excluded token should honour case insensitivity.");
}

[TestMethod]
public void FilterHonoursCaseInsensitivity()
{
List<string> strings = ["IMG_1.JPG", "IMG_2.PNG", "notes.txt"];
List<string> result = [.. TextFilter.Filter(strings, "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)];
Assert.AreSequenceEqual<string>(["IMG_1.JPG"], result, "Filter should pass the sensitivity through to IsMatch.");
}

[TestMethod]
public void FuzzyMatchingIsAlwaysCaseInsensitive()
{
// Pins the claim made in TextFilterCaseSensitivity's own docs. The setting is deliberately
// ignored here, so both values must agree - and both must agree with today's behaviour.
Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
}

[TestMethod]
public void ACatastrophicallyBacktrackingPatternTimesOutInsteadOfHanging()
{
// Filter patterns are caller-supplied, so this is the ReDoS shape: (a+)+$ against a run of
// 'a' terminated by a non-matching character backtracks exponentially. Without a timeout on
// the Regex this call does not return; with one it must come back quickly and report false.
string pattern = "(a+)+$";
string text = new string('a', 40) + "X";

System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();
bool result = TextFilter.IsMatch(text, pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWholeString);
stopwatch.Stop();

Assert.IsFalse(result, "A pattern that cannot be evaluated in time should report no match, not throw.");
Assert.IsLessThan(15_000, stopwatch.ElapsedMilliseconds, "The match should be bounded by the regex timeout rather than running unbounded.");
}

[TestMethod]
public void AnOrdinaryRegexIsUnaffectedByTheTimeout()
{
// Guards the direction the timeout could have broken: a normal pattern still matches.
Assert.IsTrue(TextFilter.IsMatch("hello world", "^hello", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString));
Assert.IsFalse(TextFilter.IsMatch("hello world", "^goodbye", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString));
}
}
Loading
Loading