Skip to content

Let a caller ask for case-insensitive matching [minor] - #98

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/textfilter-97-case-insensitive-matching
Sep 23, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
claude/textfilter-97-case-insensitive-matching

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #97

TextFilter matched case-sensitively at every TextFilterMatchOptions value and exposed no way to ask for anything else — while DotNet.Glob, the library underneath the glob path, supports case-insensitivity perfectly well. The wrapper simply never passed the option through.

// before: no way to express this at all
TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", Glob, ByWholeString)                     // false

// after
TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", Glob, ByWholeString, CaseInsensitive)    // true

Why this one

It is the recorded blocker on two adoption issues, reached independently a day apart:

Both stopped rather than shipping a ToLowerInvariant layer 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 to CaseSensitive, so nothing existing changes.
  • Threaded through IsMatch (both overloads), Filter (both), DoesMatchGlob, DoesMatchRegex, AnyTokenMatchesGlobFilter and AllTokensMatchGlobFilter, all as optional parameters.
  • Glob routes it to GlobOptions.Evaluation.CaseInsensitive; regex routes it to RegexOptions.IgnoreCase. Doing regex too was deliberate — a setting on IsMatch named for case that silently only affected glob would be a trap.

Three details worth reviewing rather than skimming:

The cache key. GlobCache and RegexCache are 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. DoesMatchGlob assigned AnyTokenMatchesGlobFilter/AllTokensMatchGlobFilter to a Func<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 CaseInsensitivityReachesRequiredAndExcludedTokens guards 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:

mutation result
glob options no longer case-insensitive 5 failed
regex options no longer case-insensitive 2 failed
cache key no longer distinguishes sensitivity 3 failed

Each mechanism is independently load-bearing, which is why all three are here. GlobCaseInsensitivityDoesNotMatchUnrelatedText passes 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:

  • My first cache test was wrong, not the code. It asserted "*.log *.txt" matches "B.TXT" case-insensitively under ByWholeString. It does not, and should not: ByWholeString AND-s the optional tokens, so *.log correctly fails. Replaced with a single-token pattern that tests the reverse cache ordering, which is what the case was actually for.
  • My first doc comment said fuzzy matching is "always case sensitive". Measured it: IsMatch("HELLO", "hello", Fuzzy) is true, so it is always case insensitive. Docs corrected and FuzzyMatchingIsAlwaysCaseInsensitive pins 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 requiredMatchFunc selection in DoesMatchGlob, which is the same region the method-group-to-lambda change touches. Both branches are cut from main and stand alone; if #96 lands first this will need a small manual resolution, and the resolution is to keep #96's always-Any choice while retaining the captured caseSensitivity argument.

The README's existing examples are fictional and I did not fix them. TextFilter.Match, MatchOptions.AnyWord and FilterType.Glob appear throughout README.md and none of them exist — the real names are IsMatch, TextFilterMatchOptions.ByWordAny and TextFilterType.Glob. This is pre-existing and the same trap as ktsu-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

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

Copy link
Copy Markdown
Contributor Author

Quality Gate fix pushed — b249339

SonarCloud failed the gate on B Security Rating on New Code. One vulnerability, and it is a fair hit:

csharpsquid:S6444 (VULNERABILITY, TextFilter.cs:409) — Pass a timeout to limit the execution time.

The new Regex(filter, ...) line is pre-existing, but this branch rewrote it to thread the case setting through, so it counts as new code. That is the right call rather than a technicality: filter is arbitrary caller-supplied pattern text, and the Regex had no matchTimeout, so a pattern with catastrophic backtracking runs unbounded on the calling thread.

Demonstrated rather than assumed. (a+)+$ against 40 as and a trailing X, through IsMatch:

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:

  1. 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.
  2. RegexMatchTimeoutException caught 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.AreEqualAssert.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
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit bc44574 into main Sep 23, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/textfilter-97-case-insensitive-matching branch September 23, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No way to ask for case-insensitive matching, which blocks two adoption issues

2 participants