From dac4134dbf24b6cd01f8c9bbfa37092ad8a6273d Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Mon, 2 Nov 2026 03:33:04 +0000 Subject: [PATCH 1/2] Add an ignore-case option to NaturalStringComparer [minor] Text chunks were always compared ordinally, so a capitalised file name dropped into a block ahead of every lowercase one. A new NaturalStringComparer(bool ignoreCase) constructor, with static Ordinal and OrdinalIgnoreCase instances, folds text the way OrdinalIgnoreCase does. Every text comparison goes through the same fold, including text against a number's first digit, so the order stays transitive. Strings that differ only in case still tie-break ordinally and never compare equal. The default is unchanged and both modes allocate nothing. Fixes ktsu-dev/Sorting#66 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3WL4nzXCqdxvXj85f --- CLAUDE.md | 2 +- README.md | 16 ++ .../NaturalStringComparerIgnoreCaseTests.cs | 153 ++++++++++++++++++ Sorting/NaturalStringComparer.cs | 86 ++++++++-- 4 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 37daa63..5cbd500 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -ktsu.Sorting is a .NET library providing advanced sorting utilities for specialized sorting scenarios. The primary component is `NaturalStringComparer`, which implements `IComparer` to sort strings containing embedded numbers in a human-friendly order (e.g., "file2" before "file10"). +ktsu.Sorting is a .NET library providing advanced sorting utilities for specialized sorting scenarios. The primary component is `NaturalStringComparer`, which implements `IComparer` to sort strings containing embedded numbers in a human-friendly order (e.g., "file2" before "file10"). It compares text ordinally by default, or ignoring case through `NaturalStringComparer.OrdinalIgnoreCase`. ## Build Commands diff --git a/README.md b/README.md index c5b0957..9dea800 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,22 @@ Array.Sort(files, comparer); var sortedFiles = files.OrderBy(file => file, new NaturalStringComparer()); ``` +### Ignoring case + +By default text is compared ordinally, so every uppercase letter sorts before every lowercase one. Use `NaturalStringComparer.OrdinalIgnoreCase` (or `new NaturalStringComparer(ignoreCase: true)`) to list names the way Windows Explorer and Finder do: + +```csharp +string[] files = { "file10.txt", "File2.txt", "file1.txt", "README.md", "build3.log", "Build20.log" }; + +Array.Sort(files, NaturalStringComparer.Ordinal); +// "Build20.log", "File2.txt", "README.md", "build3.log", "file1.txt", "file10.txt" + +Array.Sort(files, NaturalStringComparer.OrdinalIgnoreCase); +// "build3.log", "Build20.log", "file1.txt", "File2.txt", "file10.txt", "README.md" +``` + +Strings that differ only in case are still never equal under `OrdinalIgnoreCase`. They are ordered next to each other, ordinally, so a `SortedSet` keeps both `file1` and `File1`. + ## Features ### NaturalStringComparer diff --git a/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs b/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs new file mode 100644 index 0000000..7eff66f --- /dev/null +++ b/Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Sorting.Tests; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class NaturalStringComparerIgnoreCaseTests +{ + private readonly NaturalStringComparer _comparer = NaturalStringComparer.OrdinalIgnoreCase; + + [TestMethod] + public void Sort_IgnoringCase_InterleavesNamesTheWayFileManagersDo() + { + string[] expected = ["build3.log", "Build20.log", "file1.txt", "File2.txt", "file10.txt", "README.md"]; + string[] files = ["file10.txt", "File2.txt", "file1.txt", "README.md", "build3.log", "Build20.log"]; + + Array.Sort(files, _comparer); + + CollectionAssert.AreEqual(expected, files); + } + + [TestMethod] + public void Sort_ByDefault_StillPutsUppercaseFirst() + { + string[] expected = ["Build20.log", "File2.txt", "README.md", "build3.log", "file1.txt", "file10.txt"]; + string[] files = ["file10.txt", "File2.txt", "file1.txt", "README.md", "build3.log", "Build20.log"]; + string[] ordinal = [.. files]; + + Array.Sort(files, new NaturalStringComparer()); + Array.Sort(ordinal, NaturalStringComparer.Ordinal); + + CollectionAssert.AreEqual(expected, files); + CollectionAssert.AreEqual(expected, ordinal); + } + + [TestMethod] + public void Constructor_IgnoreCase_MatchesTheStaticInstance() + { + NaturalStringComparer constructed = new(ignoreCase: true); + string[] values = ["a", "A", "b", "B", "file2", "File10", "_", "5", "a5", "A_", ""]; + + foreach (string x in values) + { + foreach (string y in values) + { + Assert.AreEqual(Math.Sign(_comparer.Compare(x, y)), Math.Sign(constructed.Compare(x, y)), $"Compare(\"{x}\", \"{y}\")"); + } + } + } + + [TestMethod] + public void Compare_IgnoringCase_TiesStringsThatDifferOnlyInCaseOrdinally() + { + foreach ((string x, string y) in new[] { ("file1", "File1"), ("abc", "ABC"), ("aBc10", "AbC10"), ("img007", "IMG7") }) + { + int expected = Math.Sign(string.CompareOrdinal(x, y)); + Assert.AreNotEqual(0, expected); + Assert.AreEqual(expected, Math.Sign(_comparer.Compare(x, y)), $"Compare(\"{x}\", \"{y}\")"); + Assert.AreEqual(-expected, Math.Sign(_comparer.Compare(y, x)), $"Compare(\"{y}\", \"{x}\")"); + } + + Assert.AreEqual(0, _comparer.Compare("File1", "File1")); + } + + [TestMethod] + public void Compare_IgnoringCase_TiedStringsStayBetweenTheirNeighbours() + { + foreach (string tied in new[] { "file5", "File5", "FILE05" }) + { + Assert.IsLessThan(0, _comparer.Compare("file4", tied)); + Assert.IsLessThan(0, _comparer.Compare("FILE4", tied)); + Assert.IsGreaterThan(0, _comparer.Compare("file6", tied)); + Assert.IsGreaterThan(0, _comparer.Compare("File6", tied)); + } + } + + [TestMethod] + public void SortedSet_IgnoringCase_KeepsStringsThatDifferOnlyInCase() + { + SortedSet set = new(_comparer) { "file1", "File1", "FILE1" }; + + Assert.HasCount(3, set); + } + + [TestMethod] + public void Compare_IgnoringCase_IsTransitiveOverMixedCaseDigitsAndText() + { + // Folding moves letters relative to the punctuation between 'Z' and 'a' ("_" sorts after "a" + // once "a" reads as "A"), so the values mix both cases with that punctuation, digits in several + // scripts, and numbers meeting text. + string[] values = + [ + "", "0", "5", "05", "10", "٥", "\U0001D7D7", "a", "A", "z", "Z", "_", "^", "[", "`", "{", + " ", "-", "~", "é", "É", "ß", "ı", "I", "i", "İ", "a5", "A5", "a10", + "A_", "a_", "a[", "A[", "a`", "5a", "5A", "5_", "10a", "a.b", "A.B", "a.5", "a z", "A Z", + "file1", "File1", "file_1", "File_1", "fileA", "filea", "file10", "FILE10", "\U0001F600", + ]; + + foreach (string a in values) + { + foreach (string b in values) + { + int ab = Math.Sign(_comparer.Compare(a, b)); + Assert.AreEqual(ab, -Math.Sign(_comparer.Compare(b, a)), $"antisymmetry: '{a}' vs '{b}'"); + Assert.AreEqual(string.Equals(a, b, StringComparison.Ordinal), ab == 0, $"only equal strings tie: '{a}' vs '{b}'"); + + foreach (string c in values) + { + int bc = Math.Sign(_comparer.Compare(b, c)); + if (ab <= 0 && bc <= 0) + { + Assert.IsLessThanOrEqualTo(0, _comparer.Compare(a, c), $"transitivity: '{a}' <= '{b}' <= '{c}'"); + } + } + } + } + } + + [TestMethod] + public void Compare_IgnoringCase_AllocatesNothing() + { + (string X, string Y)[] pairs = + [ + ("file2.txt", "File10.txt"), + ("IMG007-final", "img7-Draft"), + ("a٥B", "A5c"), + ("FILE\U0001D7D7", "file\U0001D7CF\U0001D7CE"), + ("5A", "a5"), + ("Same Prefix, Longer", "same prefix"), + ("abc", "ABC"), + ]; + + foreach ((string x, string y) in pairs) + { + _comparer.Compare(x, y); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 1000; iteration++) + { + foreach ((string x, string y) in pairs) + { + _comparer.Compare(x, y); + _comparer.Compare(y, x); + } + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.AreEqual(0L, allocated, $"{allocated} bytes allocated over {pairs.Length * 2000} comparisons"); + } +} diff --git a/Sorting/NaturalStringComparer.cs b/Sorting/NaturalStringComparer.cs index 198021e..f3edbde 100644 --- a/Sorting/NaturalStringComparer.cs +++ b/Sorting/NaturalStringComparer.cs @@ -12,9 +12,38 @@ namespace ktsu.Sorting; /// in and whether or not it lies outside the Basic Multilingual Plane, and is compared by the value /// it spells rather than by its code points. A number compared with text orders exactly as the /// equivalent ASCII digits would, so every digit script sorts in the same place relative to text. +/// By default text is compared ordinally, so every uppercase letter sorts before every lowercase one; +/// compares it ignoring case instead, the way file managers list names. /// -public partial class NaturalStringComparer : IComparer +/// +/// Whether text is compared ignoring case, as folds it. +/// Strings that differ only in case are still never equal: they are ordered next to each other, +/// ordinally. +/// +public partial class NaturalStringComparer(bool ignoreCase) : IComparer { + /// + /// Gets a comparer that compares text ordinally, so case matters. This is what + /// creates. + /// + public static NaturalStringComparer Ordinal { get; } = new(ignoreCase: false); + + /// + /// Gets a comparer that compares text ordinally ignoring case, so "build3" sorts before + /// "Build20". Strings that differ only in case are still never equal: they are ordered + /// next to each other, ordinally. + /// + public static NaturalStringComparer OrdinalIgnoreCase { get; } = new(ignoreCase: true); + + /// + /// Initializes a new instance of the class that compares text + /// ordinally, so case matters. + /// + public NaturalStringComparer() + : this(ignoreCase: false) + { + } + /// /// Compares two strings using natural sorting, where embedded numbers are compared as numeric values. /// @@ -23,7 +52,8 @@ public partial class NaturalStringComparer : IComparer /// /// Less than zero if x is less than y. /// Zero only if x and y are ordinally equal; strings whose numbers differ only in leading zeros or - /// digit script are ordered next to each other, ordinally. + /// digit script, or that differ only in case when case is ignored, are ordered next to each other, + /// ordinally. /// Greater than zero if x is greater than y. /// public int Compare(string? x, string? y) @@ -73,8 +103,8 @@ public int Compare(string? x, string? y) // own code point here would sort it after letters while its ASCII equal sorts before // them, which makes the order intransitive. comparison = xIsNumeric - ? AsciiDigitAt(x, xIndex).CompareTo(y[yIndex]) - : x[xIndex].CompareTo(AsciiDigitAt(y, yIndex)); + ? AsciiDigitAt(x, xIndex).CompareTo(Fold(y[yIndex])) + : Fold(x[xIndex]).CompareTo(AsciiDigitAt(y, yIndex)); } else { @@ -98,7 +128,7 @@ public int Compare(string? x, string? y) } // Every chunk matched, but the strings differ in leading zeros or digit script ("file5" and - // "file005"). Break the tie ordinally so distinct strings never compare equal, which sorted + // "file005"), or only in case when case is ignored ("file5" and "File5"). Break the tie ordinally so distinct strings never compare equal, which sorted // collections would take to mean the same key. return string.CompareOrdinal(x, y); } @@ -161,26 +191,62 @@ private static int ChunkEnd(string value, int start, bool isNumeric) /// Where its chunk starts. /// Where its chunk ends. /// A negative number, zero, or a positive number, as for . - private static int CompareTextChunks(string x, int xStart, int xEnd, string y, int yStart, int yEnd) + private int CompareTextChunks(string x, int xStart, int xEnd, string y, int yStart, int yEnd) { int xLength = xEnd - xStart; int yLength = yEnd - yStart; - int comparison = string.CompareOrdinal(x, xStart, y, yStart, Math.Min(xLength, yLength)); + int length = Math.Min(xLength, yLength); + int comparison = ignoreCase + ? CompareFolded(x, xStart, y, yStart, length) + : string.CompareOrdinal(x, xStart, y, yStart, length); if (comparison != 0 || xLength == yLength) { return comparison; } // "file.txt" against "file" then "1": the '.' meets the '1', not the end of "file". A text chunk - // never holds a digit, so the two characters always differ. + // never holds a digit, and folding never makes a digit, so the two characters always differ. if (xLength < yLength) { - return xEnd < x.Length ? AsciiDigitAt(x, xEnd).CompareTo(y[yStart + xLength]) : -1; + return xEnd < x.Length ? AsciiDigitAt(x, xEnd).CompareTo(Fold(y[yStart + xLength])) : -1; + } + + return yEnd < y.Length ? Fold(x[xStart + yLength]).CompareTo(AsciiDigitAt(y, yEnd)) : 1; + } + + /// + /// Compares characters of two strings one at a time, each folded to upper + /// case as folds it. + /// + /// The first string. + /// Where to start reading it. + /// The second string. + /// Where to start reading it. + /// How many characters to compare. + /// A negative number, zero, or a positive number, as for . + private static int CompareFolded(string x, int xStart, string y, int yStart, int length) + { + for (int offset = 0; offset < length; offset++) + { + int comparison = char.ToUpperInvariant(x[xStart + offset]).CompareTo(char.ToUpperInvariant(y[yStart + offset])); + if (comparison != 0) + { + return comparison; + } } - return yEnd < y.Length ? x[xStart + yLength].CompareTo(AsciiDigitAt(y, yEnd)) : 1; + return 0; } + /// + /// Returns the character text is compared by: itself, or its upper case + /// form when case is ignored. Every comparison of a text character goes through here, including + /// against a number's first digit, so the order stays transitive whichever way case is handled. + /// + /// A character from a text chunk. + /// The character to compare. + private char Fold(char value) => ignoreCase ? char.ToUpperInvariant(value) : value; + /// /// Compares two runs of decimal digits by the numeric value they spell, rather than by their /// code points. From fd4a414f90786e2ecb347581f27db5ed4e770dc8 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Mon, 2 Nov 2026 03:33:27 +0000 Subject: [PATCH 2/2] Leave CLAUDE.md's overview line to the IComparer change The README documents the ignore-case option. Editing the same CLAUDE.md line as the non-generic IComparer PR only made the two PRs conflict. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3WL4nzXCqdxvXj85f --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5cbd500..37daa63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -ktsu.Sorting is a .NET library providing advanced sorting utilities for specialized sorting scenarios. The primary component is `NaturalStringComparer`, which implements `IComparer` to sort strings containing embedded numbers in a human-friendly order (e.g., "file2" before "file10"). It compares text ordinally by default, or ignoring case through `NaturalStringComparer.OrdinalIgnoreCase`. +ktsu.Sorting is a .NET library providing advanced sorting utilities for specialized sorting scenarios. The primary component is `NaturalStringComparer`, which implements `IComparer` to sort strings containing embedded numbers in a human-friendly order (e.g., "file2" before "file10"). ## Build Commands