fix: match canonically equivalent NFC and NFD text [patch] - #72
Merged
Merged
Conversation
Fuzzy.Contains and CalculateScore compared subject and pattern per UTF-16
char with no Unicode normalization, so precomposed text (e.g. "café" with
U+00E9) never matched its canonically equivalent decomposed form ("cafe" +
U+0301). NFD is the normal form for filenames on HFS+/APFS, so consumers
matching against such paths silently missed matches for visibly identical
text.
Both inputs are now normalized to FormC before matching. The common case
costs nothing: ASCII-only text is already in FormC, so it is detected by an
allocation-free scan and passed through unchanged. Only text containing a
non-ASCII character pays for the conversion. Text that is not well-formed
Unicode (a lone surrogate) cannot be normalized, so it is compared as given
rather than throwing out of a method that previously never threw.
Normalization needs the runtime's globalization data, which invariant mode
does not provide, so the test project opts out of invariant globalization
and the limitation is documented on the type.
Fixes #70
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W78KwkEpp9G2ZNXfm6VVqL
Introducing ContainsCore between the two public Contains overloads split them apart, which SonarQube flags as S4136. Move ContainsCore below both. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W78KwkEpp9G2ZNXfm6VVqL
Contributor
Author
|
On SonarQube's 2 new issues (gate passed, but for the record):
Generated by Claude Code |
|
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #70
The problem
Fuzzy.ContainsandCalculateScorecompared subject and pattern per UTF-16charwith no Unicode normalization. Precomposed text (café, with U+00E9) and its canonically equivalent decomposed form (cafe+ U+0301 COMBINING ACUTE ACCENT) are the same visible text but different code-unit sequences, so they never matched:NFD is the normal form for filenames on HFS+/APFS, so a consumer matching against such paths silently missed matches.
The fix
Both inputs are normalized to
NormalizationForm.FormCbefore matching.ContainsandCalculateScorebecame thin normalizing wrappers overContainsCore/CalculateScoreCore; the matching loop itself is untouched.Two details worth review:
NormalizeForComparisondetects it with a plain scan and returns the original span. Only text containing a non-ASCII character pays forToString().Normalize(...). This addresses the allocation concern raised in the issue's triage comment.string.NormalizethrowsArgumentExceptionon input that is not well-formed (a lone surrogate — see Matching operates on UTF-16 code units, so a lone surrogate can spuriously match inside an unrelated emoji #71). That is caught and the text is compared as given, so these methods keep their existing "never throws" contract.Invariant globalization caveat
Normalization needs the runtime's globalization data. Under invariant globalization
string.Normalizeis a no-op, and the test project inheritsInvariantGlobalization=truefromktsu.Sdk— which is why the new tests failed until the test project opted out. The test project now setsInvariantGlobalization=false, and the limitation is documented in theFuzzyXML remarks.Worth flagging separately: any consumer app that runs with invariant globalization (the
ktsu.Sdkdefault) gets no normalization and will still see this behaviour. Whether that default should change is out of scope here.Testing
Five tests added under a new
Unicode Normalization Testsregion: NFD subject vs NFC pattern, the reverse, equal scores across the two forms, a non-match that stays a non-match, and the lone-surrogate case.Verified by reverting
FuzzySearch/Fuzzy.csto its pre-fix state and re-running with the test changes in place — exactly the three normalization tests failed (3 failed / 31 passed), and all 34 pass with the fix. Full suite green, Debug and Release both build with 0 warnings.🤖 Generated with Claude Code
https://claude.ai/code/session_01W78KwkEpp9G2ZNXfm6VVqL
Generated by Claude Code