Let a caller ask for case-insensitive matching [minor] - #98
Conversation
Glob and regex matching were case sensitive at every TextFilterMatchOptions value, with no way to ask for anything else, even though DotNet.Glob supports case-insensitivity perfectly well and the wrapper simply never passed the option through. Adds TextFilterCaseSensitivity, defaulting to CaseSensitive so no existing behaviour changes. Glob routes it to GlobOptions.Evaluation.CaseInsensitive and regex to RegexOptions.IgnoreCase. Both caches are keyed by pattern text, so the sensitivity is folded into the key; without that, whichever variant was compiled first would decide the answer for the other. Fuzzy matching does not take the setting and is documented as always case insensitive, which is what it already was. Fixes #97 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VECND7h5BXVvufyf9jbpwT
SonarCloud S6444 on the previous commit: the Regex constructed from the caller-supplied filter had no timeout, so a pattern with catastrophic backtracking runs unbounded on the calling thread. The line is pre-existing but this branch rewrote it, which is fair enough - it is a real ReDoS on a public entry point that takes arbitrary pattern text. Passes a one-second match timeout, and catches RegexMatchTimeoutException at the match site rather than letting the new throw path reach callers: filtering is a predicate, and a list that throws mid-keystroke on a pathological pattern is a worse contract than one that returns nothing for it. That mirrors how an invalid pattern already degrades. Also switches the one new CollectionAssert.AreEqual to Assert.AreSequenceEqual for MSTEST0068. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VECND7h5BXVvufyf9jbpwT
Quality Gate fix pushed —
|
| result | |
|---|---|
| no timeout (the code as it stood) | test run never completes — killed at 75 s |
| one-second timeout, exception uncaught | RegexMatchTimeoutException escapes to the caller |
| both, as pushed | returns false in ~1 s |
So the fix is two things, and each is independently load-bearing:
- A one-second match timeout on the constructed
Regex. Far longer than any legitimate filter needs, short enough that a pathological one cannot wedge a UI. RegexMatchTimeoutExceptioncaught at the match site, treated as no-match for that token. Adding the timeout without this would have traded an unbounded hang for a new exception path on a public predicate — filtering is a predicate, and a list that throws mid-keystroke is a worse contract than one that returns nothing. It mirrors how an invalid pattern already degrades to match-everything.
Two tests: ACatastrophicallyBacktrackingPatternTimesOutInsteadOfHanging (the case above, asserting both the false and the bound) and AnOrdinaryRegexIsUnaffectedByTheTimeout as the guard against the timeout reaching patterns that were always fine.
Also took the one MSTEST0068 INFO finding on my own new line — CollectionAssert.AreEqual → Assert.AreSequenceEqual. Note the rest of the file still uses CollectionAssert.AreEqual in about a dozen pre-existing places; I did not sweep those, since they are outside this diff.
81 total, 0 failed (68 before this PR, 13 added), all five TFMs. Release build clean, 0 warnings.
One thing I did not do
RegexMatchAnything() also constructs a Regex without a timeout and is untouched. Sonar did not flag it and it is not reachable from caller input — its pattern is the literal ".*", which has no backtracking to be catastrophic. Left alone rather than widened into.
Generated by Claude Code
PR #96 landed after this branch was cut and rewrote the same requiredMatchFunc selection in DoesMatchGlob that the method-group-to- lambda change here touches, leaving the PR conflicted. Resolved as the PR described: keep #96's always-Any choice for required tokens, and retain the captured caseSensitivity argument so the required call site still honours the sensitivity. Both sides' tests survive the resolution: 84 pass, 0 fail (this branch's 79 plus #96's 5). Restoring this branch's side of the hunk fails 2 of #96's ByWordAll required-token tests, which is what pins the choice. Release build clean across all five TFMs, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017wK8pxTxpgg3R2m3siPY3Y
|



Fixes #97
TextFiltermatched case-sensitively at everyTextFilterMatchOptionsvalue and exposed no way to ask for anything else — whileDotNet.Glob, the library underneath the glob path, supports case-insensitivity perfectly well. The wrapper simply never passed the option through.Why this one
It is the recorded blocker on two adoption issues, reached independently a day apart:
TextFilter's glob semantics against its ownRepositoryAllowListTestsand found them an exact match on every case except this. Its conclusion: "Best unblocker would be upstream: a case-insensitivity option onTextFilter.IsMatch's glob path."TextFilterin the file-open dialog would have stopped*.jpglistingIMG_1234.JPG, which is what a camera writes. A user-visible regression for a cleanup with no user-visible upside.Both stopped rather than shipping a
ToLowerInvariantlayer in the consumer, which was the right call: case-folding at each call site is exactly the hand-rolled code that adopting a shared library is supposed to delete.Scope
TextFilterCaseSensitivity, defaulting toCaseSensitive, so nothing existing changes.IsMatch(both overloads),Filter(both),DoesMatchGlob,DoesMatchRegex,AnyTokenMatchesGlobFilterandAllTokensMatchGlobFilter, all as optional parameters.GlobOptions.Evaluation.CaseInsensitive; regex routes it toRegexOptions.IgnoreCase. Doing regex too was deliberate — a setting onIsMatchnamed for case that silently only affected glob would be a trap.Three details worth reviewing rather than skimming:
The cache key.
GlobCacheandRegexCacheare both keyed by pattern text. Without the sensitivity folded into the key, the same pattern compiled at two sensitivities collides and whichever was compiled first decides the answer for the other — an order-dependent wrong answer, which is the worst shape a caching bug comes in. Folded in as a one-character prefix rather than a tuple key, which netstandard2.0 does not get for free.Method groups became lambdas.
DoesMatchGlobassignedAnyTokenMatchesGlobFilter/AllTokensMatchGlobFilterto aFunc<string, HashSet<string>, bool>as method groups. A method group conversion will not bind an optional parameter, so those had to become explicit lambdas capturing the sensitivity. Called out because it looks like gratuitous churn and is not.Required and excluded tokens have their own call sites, separate from the optional branch. Threading the sensitivity into the optional path alone would have left them behind, silently, so
CaseInsensitivityReachesRequiredAndExcludedTokensguards both.Tests
11 cases added. Full suite green: 79 total, 0 failed (68 before, 11 added), across all five TFMs. Release build of the solution clean, 0 warnings.
Confirmed the tests depend on the change by mutating each of the three mechanisms separately and re-running:
Each mechanism is independently load-bearing, which is why all three are here.
GlobCaseInsensitivityDoesNotMatchUnrelatedTextpasses either way by design — it guards against the change widening the match beyond case-folding, and is not evidence for the fix.Two things I corrected rather than assumed:
"*.log *.txt"matches"B.TXT"case-insensitively underByWholeString. It does not, and should not:ByWholeStringAND-s the optional tokens, so*.logcorrectly fails. Replaced with a single-token pattern that tests the reverse cache ordering, which is what the case was actually for.IsMatch("HELLO", "hello", Fuzzy)istrue, so it is always case insensitive. Docs corrected andFuzzyMatchingIsAlwaysCaseInsensitivepins the claim, asserting both sensitivity values agree since the setting is deliberately ignored there.Not covered, and two notes
Possible conflict with #96. That PR rewrites the
requiredMatchFuncselection inDoesMatchGlob, which is the same region the method-group-to-lambda change touches. Both branches are cut frommainand stand alone; if #96 lands first this will need a small manual resolution, and the resolution is to keep #96's always-Anychoice while retaining the capturedcaseSensitivityargument.The README's existing examples are fictional and I did not fix them.
TextFilter.Match,MatchOptions.AnyWordandFilterType.Globappear throughoutREADME.mdand none of them exist — the real names areIsMatch,TextFilterMatchOptions.ByWordAnyandTextFilterType.Glob. This is pre-existing and the same trap asktsu-dev/FuzzySearch#74. My new section is written against the real API, which makes the file internally inconsistent until the rest is corrected; that felt better than matching a fictional style for consistency's sake, but it is worth its own issue and I did not want to bury a full README rewrite in this diff.Two further findings are recorded in #97 but deliberately not changed here: a glob containing a literal space is inexpressible (by design — the space is the filter DSL's token separator, so ImGuiApp#409's "wrapper defect" framing is corrected in the issue), and
**does not match the empty string.🤖 Generated with Claude Code
https://claude.ai/code/session_01VECND7h5BXVvufyf9jbpwT
Generated by Claude Code