From c0d7a55a560db3ac8e88ddfff4a665487e31392c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 10:30:13 +0000 Subject: [PATCH] Leave a long s unfolded in OrdinalIgnoreCase, as StringComparison does [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit char.ToUpperInvariant('ſ') is 'S', but StringComparison.OrdinalIgnoreCase leaves U+017F alone, so NaturalStringComparer.OrdinalIgnoreCase sorted "ſtraße2.txt" before "Tag1.txt" where OrdinalIgnoreCase puts it after. Fold through one helper that keeps U+017F as it is, in both Fold and CompareFolded. The test project now runs with invariant globalization off: ktsu.Sdk turns it on, and invariant casing already leaves U+017F alone, which hid the bug from the tests. Fixes #79 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW66FJ9HR9vCd1x5h4H --- .../NaturalStringComparerIgnoreCaseTests.cs | 48 +++++++++++++++++++ Sorting.Test/Sorting.Test.csproj | 3 ++ Sorting/NaturalStringComparer.cs | 20 +++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs b/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs index 7eff66f..46add13 100644 --- a/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs +++ b/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs @@ -117,6 +117,54 @@ public void Compare_IgnoringCase_IsTransitiveOverMixedCaseDigitsAndText() } } + [TestMethod] + public void Compare_IgnoringCase_LeavesLongSUnfoldedAsOrdinalIgnoreCaseDoes() + { + // char.ToUpperInvariant('ſ') is 'S', but OrdinalIgnoreCase leaves U+017F as it is. + Assert.IsTrue(_comparer.Compare("ſ", "t") > 0, "ſ sorts after t"); + Assert.IsTrue(_comparer.Compare("t", "ſ") < 0, "t sorts before ſ"); + + foreach (string other in new[] { "s", "S", "t", "T" }) + { + int expected = Math.Sign(string.Compare("ſ", other, StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(expected, Math.Sign(_comparer.Compare("ſ", other)), $"Compare(\"ſ\", \"{other}\")"); + Assert.AreEqual(-expected, Math.Sign(_comparer.Compare(other, "ſ")), $"Compare(\"{other}\", \"ſ\")"); + } + + string[] files = ["Tag1.txt", "ſtraße2.txt", "Rose1.txt"]; + string[] expectedOrder = [.. files]; + Array.Sort(files, _comparer); + Array.Sort(expectedOrder, StringComparer.OrdinalIgnoreCase); + CollectionAssert.AreEqual(expectedOrder, files); + } + + [TestMethod] + public void Compare_IgnoringCase_FoldsEveryBmpLetterAsOrdinalIgnoreCaseDoes() + { + string[] probes = ["a", "s", "S", "t", "T", "z", "ß", "é", "Ω", "я"]; + + for (int code = 0; code <= char.MaxValue; code++) + { + char c = (char)code; + if (char.IsSurrogate(c) || !char.IsLetter(c)) + { + continue; + } + + string value = c.ToString(); + foreach (string probe in probes) + { + int expected = Math.Sign(string.Compare(value, probe, StringComparison.OrdinalIgnoreCase)); + if (expected == 0) + { + continue; + } + + Assert.AreEqual(expected, Math.Sign(_comparer.Compare(value, probe)), $"Compare(U+{code:X4}, \"{probe}\")"); + } + } + } + [TestMethod] public void Compare_IgnoringCase_AllocatesNothing() { diff --git a/Sorting.Test/Sorting.Test.csproj b/Sorting.Test/Sorting.Test.csproj index 1b37988..eb0c079 100644 --- a/Sorting.Test/Sorting.Test.csproj +++ b/Sorting.Test/Sorting.Test.csproj @@ -6,6 +6,9 @@ true net10.0 + + false diff --git a/Sorting/NaturalStringComparer.cs b/Sorting/NaturalStringComparer.cs index 4d8e893..acfa190 100644 --- a/Sorting/NaturalStringComparer.cs +++ b/Sorting/NaturalStringComparer.cs @@ -256,7 +256,7 @@ private static int CompareFolded(string x, int xStart, string y, int yStart, int { for (int offset = 0; offset < length; offset++) { - int comparison = char.ToUpperInvariant(x[xStart + offset]).CompareTo(char.ToUpperInvariant(y[yStart + offset])); + int comparison = FoldCase(x[xStart + offset]).CompareTo(FoldCase(y[yStart + offset])); if (comparison != 0) { return comparison; @@ -273,7 +273,23 @@ private static int CompareFolded(string x, int xStart, string y, int yStart, int /// /// A character from a text chunk. /// The character to compare. - private char Fold(char value) => ignoreCase ? char.ToUpperInvariant(value) : value; + private char Fold(char value) => ignoreCase ? FoldCase(value) : value; + + /// + /// Folds to upper case the way + /// does. That is for every character but 'ſ' (U+017F, + /// LATIN SMALL LETTER LONG S): maps it to 'S', while + /// leaves it as it is. + /// + /// The character to fold. + /// The folded character. + private static char FoldCase(char value) => value == LatinSmallLetterLongS ? value : char.ToUpperInvariant(value); + + /// + /// U+017F LATIN SMALL LETTER LONG S, the one BMP character whose + /// differs from the fold applies. + /// + private const char LatinSmallLetterLongS = '\u017F'; /// /// Compares two runs of decimal digits by the numeric value they spell, rather than by their