Skip to content

Fold letters outside the BMP in OrdinalIgnoreCase [patch] - #78

Open
matt-edmondson wants to merge 1 commit into
mainfrom
claude/sorting-77-fold-supplementary
Open

matt-edmondson wants to merge 1 commit into
mainfrom
claude/sorting-77-fold-supplementary

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #77

What was wrong

NaturalStringComparer.OrdinalIgnoreCase folded one UTF-16 code unit at a time. char.ToUpperInvariant returns a surrogate unchanged, so cased letters encoded as surrogate pairs (Adlam, Deseret, Osage, Warang Citi, Medefaidrin) were never folded. As a result "\U00010428a" sorted after "\U00010400b", and 𞤢file / 𞤀file didn't sort next to each other.

Change

  • Fold(char) becomes Fold(string, int). With ignore case on, each code unit is folded as part of the code point it belongs to (FoldIgnoringCase): Rune.ToUpperInvariant on .NET, and TextInfo.ToUpper on netstandard, which has no Rune. On netstandard this allocates, but only for a surrogate pair in a text chunk.
  • All three text comparisons use the same fold: CompareFolded, text against a number's first digit, and the prefix branch of CompareTextChunks. This keeps the order transitive. Folding never changes a code point's width, so a pair that straddles the shorter chunk's end still compares consistently.
  • Folded text is still compared by code unit, the same way Ordinal mode and the ordinal tiebreak order text. This differs from string.Compare(..., OrdinalIgnoreCase) in one place: U+E000–U+FFFF against a surrogate pair. The code comments and the README document it.

Tests

New tests in NaturalStringComparerIgnoreCaseTests:

  • Compare_IgnoringCase_FoldsLettersOutsideTheBmp: the Deseret and Adlam cases from the issue.
  • Sort_IgnoringCase_KeepsSupplementaryNamesEqualIgnoringCaseTogether: 𞤢file and 𞤀file end up adjacent, and they still tie-break ordinally rather than comparing as 0.
  • Compare_IgnoringCase_FoldsAsOrdinalIgnoreCaseDoes: agreement with OrdinalIgnoreCase plus the ordinal tiebreak, over BMP and supplementary letters.
  • Compare_IgnoringCase_IsTransitiveOverBmpAndSupplementaryLetters: a mixed BMP/supplementary set, including lone surrogates, U+E000+, digits, and pairs straddling chunk ends.
  • The zero-allocation test now includes a supplementary pair.

Verification:

  • With the fix reverted, 3 of the new tests fail.
  • With the fix, the full suite passes: 43/43 on net10.0.
  • dotnet build -c Release of the library across all TFMs (net8/9/10, netstandard2.0/2.1) succeeds with no warnings.

Generated by Claude Code

OrdinalIgnoreCase folded one UTF-16 code unit at a time, and
char.ToUpperInvariant leaves a surrogate unchanged, so a cased letter
encoded as a surrogate pair (Adlam, Deseret, Osage, Warang Citi) was
never folded: "\U00010428a" sorted after "\U00010400b", and names equal
ignoring case did not sort next to each other.

Each code unit is now folded as part of the code point it belongs to,
with Rune.ToUpperInvariant on .NET and TextInfo.ToUpper on netstandard.
Every text comparison still goes through the same fold, including text
against a number's first digit and the prefix branch, so the order
stays transitive. Folded text is still compared by code unit, the way
Ordinal and the ordinal tiebreak order it, and the README says where
that differs from OrdinalIgnoreCase (U+E000..U+FFFF against a pair).

Fixes #77

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

sonarqubecloud Bot commented Nov 4, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants