Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` keeps both `file1` and `File1`.

## Features

### NaturalStringComparer
Expand Down
153 changes: 153 additions & 0 deletions Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs
Original file line number Diff line number Diff line change
@@ -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);

Check warning on line 20 in Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Sorting&issues=AaGaWJDBF-cdVWH8P4uY&open=AaGaWJDBF-cdVWH8P4uY&pullRequest=76
}

[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);

Check warning on line 33 in Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Sorting&issues=AaGaWJDBF-cdVWH8P4uZ&open=AaGaWJDBF-cdVWH8P4uZ&pullRequest=76
CollectionAssert.AreEqual(expected, ordinal);

Check warning on line 34 in Sorting.Test/NaturalStringComparerIgnoreCaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Sorting&issues=AaGaWJDBF-cdVWH8P4ua&open=AaGaWJDBF-cdVWH8P4ua&pullRequest=76
}

[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<string> 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");
}
}
86 changes: 76 additions & 10 deletions Sorting/NaturalStringComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,38 @@
/// 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;
/// <see cref="OrdinalIgnoreCase"/> compares it ignoring case instead, the way file managers list names.
/// </remarks>
public partial class NaturalStringComparer : IComparer<string?>
/// <param name="ignoreCase">
/// Whether text is compared ignoring case, as <see cref="StringComparison.OrdinalIgnoreCase"/> folds it.
/// Strings that differ only in case are still never equal: they are ordered next to each other,
/// ordinally.
/// </param>
public partial class NaturalStringComparer(bool ignoreCase) : IComparer<string?>
{
/// <summary>
/// Gets a comparer that compares text ordinally, so case matters. This is what
/// <see cref="NaturalStringComparer()"/> creates.
/// </summary>
public static NaturalStringComparer Ordinal { get; } = new(ignoreCase: false);

/// <summary>
/// Gets a comparer that compares text ordinally ignoring case, so <c>"build3"</c> sorts before
/// <c>"Build20"</c>. Strings that differ only in case are still never equal: they are ordered
/// next to each other, ordinally.
/// </summary>
public static NaturalStringComparer OrdinalIgnoreCase { get; } = new(ignoreCase: true);

/// <summary>
/// Initializes a new instance of the <see cref="NaturalStringComparer"/> class that compares text
/// ordinally, so case matters.
/// </summary>
public NaturalStringComparer()
: this(ignoreCase: false)
{
}

/// <summary>
/// Compares two strings using natural sorting, where embedded numbers are compared as numeric values.
/// </summary>
Expand All @@ -23,10 +52,11 @@
/// <returns>
/// 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.
/// </returns>
public int Compare(string? x, string? y)

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

Check warning on line 59 in Sorting/NaturalStringComparer.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.
{
if (x == null && y == null)
{
Expand Down Expand Up @@ -73,8 +103,8 @@
// 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
{
Expand All @@ -98,7 +128,7 @@
}

// 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);
}
Expand Down Expand Up @@ -161,26 +191,62 @@
/// <param name="yStart">Where its chunk starts.</param>
/// <param name="yEnd">Where its chunk ends.</param>
/// <returns>A negative number, zero, or a positive number, as for <see cref="Compare"/>.</returns>
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;
}

/// <summary>
/// Compares <paramref name="length"/> characters of two strings one at a time, each folded to upper
/// case as <see cref="StringComparison.OrdinalIgnoreCase"/> folds it.
/// </summary>
/// <param name="x">The first string.</param>
/// <param name="xStart">Where to start reading it.</param>
/// <param name="y">The second string.</param>
/// <param name="yStart">Where to start reading it.</param>
/// <param name="length">How many characters to compare.</param>
/// <returns>A negative number, zero, or a positive number, as for <see cref="Compare"/>.</returns>
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;
}

/// <summary>
/// Returns the character text is compared by: <paramref name="value"/> 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.
/// </summary>
/// <param name="value">A character from a text chunk.</param>
/// <returns>The character to compare.</returns>
private char Fold(char value) => ignoreCase ? char.ToUpperInvariant(value) : value;

/// <summary>
/// Compares two runs of decimal digits by the numeric value they spell, rather than by their
/// code points.
Expand Down
Loading