Skip to content

fix: match by Unicode codepoint so a lone surrogate cannot match half a pair [patch] - #73

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-3myalr
Sep 14, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-3myalr

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #71

What was wrong

Fuzzy.ContainsCore and Fuzzy.CalculateScoreCore advanced one UTF-16 code unit at a time, with no awareness of surrogate pairs. An unpaired surrogate in the pattern was therefore an ordinary matchable character and could match the corresponding half of an unrelated supplementary-plane character in the subject:

Fuzzy.Contains("x\U0001F601y", "\uD83D"); // → true, before this change

The fix

Both loops now advance one Unicode codepoint at a time:

  • CodepointLengthAt reports whether an index starts a well-formed surrogate pair (2 code units) or not (1). An unpaired surrogate is a codepoint of its own, so it is never treated as half of a neighbouring character.
  • CodepointsEqual compares the codepoints at two indices, treating codepoints of differing code-unit length as unequal — that length check is what stops the spurious half-match.
  • The scoring loop tracks the best matched codepoint by index and length rather than by char, so a surrogate pair is scored as one character instead of two.

char.IsHighSurrogate / char.IsLowSurrogate are used rather than System.Text.Rune, which is not available on the netstandard2.0 / netstandard2.1 legs.

Behaviour notes

  • BMP text is unchanged. Every BMP character occupies a single code unit, so both loops iterate exactly as they did before — same matches, same scores.
  • Supplementary-plane codepoints are compared exactly, which is also the existing behaviour: char.ToLowerInvariant operates on single UTF-16 code units and has no case mapping to apply to a surrogate. Case folding for supplementary-plane characters is out of scope here.
  • No public API change.

This issue was previously triaged as a follow-on to #70; that issue was resolved by PR #72 via NFC normalization rather than codepoint enumeration, so this gap remained and is addressed here on its own terms.

Testing

Added a Surrogate Pair Tests region to FuzzySearch.Test/FuzzyTests.cs covering the issue's acceptance criteria — an unpaired high surrogate, an unpaired low surrogate, and the scoring overload against a subject containing U+1F601 adjacent to other matchable characters, plus regression guards that a pair still matches itself, that an unpaired surrogate still matches the same unpaired surrogate, that a whole pair does not match an unpaired surrogate in the subject, and that two emoji sharing a high surrogate are not confused.

Verified by reverting FuzzySearch/Fuzzy.cs to main with the new tests in place: 3 of the new tests fail (Contains_LoneHighSurrogatePattern_DoesNotMatchHalfOfASurrogatePair, Contains_LoneLowSurrogatePattern_DoesNotMatchHalfOfASurrogatePair, Contains_WithScore_LoneHighSurrogatePattern_IsNotReportedAsPresent). With the fix restored, all 42 tests pass, and dotnet build -c Release succeeds with 0 warnings across all five target frameworks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rcu8VsXN5GQsBmuvn6NQ6y


Generated by Claude Code

… a pair [patch]

Fuzzy.ContainsCore and Fuzzy.CalculateScoreCore advanced one UTF-16 code
unit at a time, so an unpaired surrogate in the pattern matched the
corresponding half of an unrelated supplementary-plane character in the
subject: Fuzzy.Contains("x\U0001F601y", "\uD83D") returned true.

Both loops now advance one Unicode codepoint at a time. CodepointLengthAt
reports whether an index starts a well-formed surrogate pair, and
CodepointsEqual treats codepoints of differing code-unit length as unequal,
which is what stops the spurious half-match. The scoring loop tracks the
best matched codepoint by index and length rather than by char, so a
surrogate pair is scored as one character instead of two.

Behaviour for BMP text is unchanged: every BMP character is a single code
unit, so both loops iterate exactly as before. Supplementary-plane
codepoints are compared exactly, matching the existing behaviour, since
char.ToLowerInvariant has no case mapping to apply to a surrogate.

Fixes #71

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcu8VsXN5GQsBmuvn6NQ6y
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 1d93494 into main Sep 14, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-3myalr branch September 14, 2026 16:29
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.

Matching operates on UTF-16 code units, so a lone surrogate can spuriously match inside an unrelated emoji

1 participant