diff --git a/Sorting.Test/NaturalStringComparerTests.cs b/Sorting.Test/NaturalStringComparerTests.cs
index 9ff08fa..da415ed 100644
--- a/Sorting.Test/NaturalStringComparerTests.cs
+++ b/Sorting.Test/NaturalStringComparerTests.cs
@@ -101,6 +101,48 @@ public void Compare_StringsWithLeadingZeros_HandledCorrectly()
Assert.IsLessThan(0, _comparer.Compare("file005", "file06")); // 5 < 6 numerically
}
+ [TestMethod]
+ public void Compare_NonAsciiDigits_ComparedByNumericValue()
+ {
+ // Arabic-Indic five is numerically less than ASCII nine, despite the far greater code point
+ Assert.IsLessThan(0, _comparer.Compare("٥", "9"));
+ Assert.IsGreaterThan(0, _comparer.Compare("9", "٥"));
+
+ // The same holds within a single non-ASCII script
+ Assert.IsLessThan(0, _comparer.Compare("٥", "٩")); // Arabic-Indic 5 < 9
+
+ // Devanagari digits order by value too
+ Assert.IsLessThan(0, _comparer.Compare("५", "३०")); // 5 < 30
+ }
+
+ [TestMethod]
+ public void Compare_NonAsciiDigits_EqualValuesAreEqual()
+ {
+ // Arabic-Indic five and ASCII five spell the same number
+ Assert.AreEqual(0, _comparer.Compare("٥", "5"));
+ Assert.AreEqual(0, _comparer.Compare("file٥", "file5"));
+ }
+
+ [TestMethod]
+ public void Compare_NonAsciiLeadingZeros_NormalizedLikeAsciiZeros()
+ {
+ // An Arabic-Indic zero is a leading zero, so both chunks reduce to the single digit 0
+ Assert.AreEqual(0, _comparer.Compare("٠0", "0"));
+ Assert.AreEqual(0, _comparer.Compare("٠٥", "5"));
+
+ // An all-zeros chunk compares as zero, whatever the script
+ Assert.AreEqual(0, _comparer.Compare("٠٠", "0"));
+ Assert.IsLessThan(0, _comparer.Compare("٠٠", "1"));
+ }
+
+ [TestMethod]
+ public void Compare_MixedScriptDigits_ComparedByNumericValue()
+ {
+ // A single chunk may mix scripts; it still spells one number
+ Assert.IsLessThan(0, _comparer.Compare("file1٥", "file20")); // 15 < 20
+ Assert.AreEqual(0, _comparer.Compare("file1٥", "file15"));
+ }
+
[TestMethod]
public void Compare_DifferentLengthStrings_ShorterComesFirst()
{
diff --git a/Sorting/NaturalStringComparer.cs b/Sorting/NaturalStringComparer.cs
index 361844d..b884e81 100644
--- a/Sorting/NaturalStringComparer.cs
+++ b/Sorting/NaturalStringComparer.cs
@@ -2,11 +2,16 @@
namespace ktsu.Sorting;
+using System.Globalization;
using System.Text.RegularExpressions;
///
/// Comparer that performs a natural comparison between strings, correctly comparing embedded numbers.
///
+///
+/// A run of Unicode decimal digits (category Nd) is a number, whatever script it is written
+/// in, and is compared by the value it spells rather than by its code points.
+///
public partial class NaturalStringComparer : IComparer
{
///
@@ -80,27 +85,70 @@ public int Compare(string? x, string? y)
return xMatches.Length.CompareTo(yMatches.Length);
}
+ ///
+ /// Compares two chunks of decimal digits by the numeric value they spell, rather than by their
+ /// code points.
+ ///
+ ///
+ /// Both chunks come from the \d+ alternative of the chunk regex, so every character is a
+ /// Unicode decimal digit (category Nd) and has a decimal value of 0-9. Comparing those
+ /// values, rather than the raw UTF-16 code points, is what keeps non-ASCII digit scripts
+ /// ordering by magnitude: '٥' (Arabic-Indic five) is numerically less than '9',
+ /// even though its code point is far greater.
+ ///
+ /// First digit chunk to compare.
+ /// Second digit chunk to compare.
+ /// A negative number, zero, or a positive number, as for .
private static int CompareNumericChunks(string xChunk, string yChunk)
{
- string xTrimmed = xChunk.TrimStart('0');
- string yTrimmed = yChunk.TrimStart('0');
+ int xStart = SkipLeadingZeros(xChunk);
+ int yStart = SkipLeadingZeros(yChunk);
- if (xTrimmed.Length == 0)
+ // With leading zeros gone, the chunk spelling more digits is the larger number
+ int xDigits = xChunk.Length - xStart;
+ int yDigits = yChunk.Length - yStart;
+ int lengthComparison = xDigits.CompareTo(yDigits);
+ if (lengthComparison != 0)
{
- xTrimmed = "0";
+ return lengthComparison;
}
- if (yTrimmed.Length == 0)
+ // Same digit count, so the first differing digit decides
+ for (int offset = 0; offset < xDigits; offset++)
{
- yTrimmed = "0";
+ int digitComparison = DigitValue(xChunk[xStart + offset]).CompareTo(DigitValue(yChunk[yStart + offset]));
+ if (digitComparison != 0)
+ {
+ return digitComparison;
+ }
}
- int lengthComparison = xTrimmed.Length.CompareTo(yTrimmed.Length);
- if (lengthComparison != 0)
+ return 0;
+ }
+
+ ///
+ /// Returns the index of the first digit in that is not a zero, or
+ /// chunk.Length - 1 when the chunk is all zeros, so a chunk of zeros compares as a
+ /// single zero digit.
+ ///
+ /// The digit chunk to scan.
+ /// The index at which the chunk's significant digits begin.
+ private static int SkipLeadingZeros(string chunk)
+ {
+ int index = 0;
+ while (index < chunk.Length - 1 && DigitValue(chunk[index]) == 0)
{
- return lengthComparison;
+ index++;
}
- return string.Compare(xTrimmed, yTrimmed, StringComparison.Ordinal);
+ return index;
}
+
+ ///
+ /// Returns the decimal value of a Unicode decimal digit, so that digits from any script compare
+ /// by magnitude.
+ ///
+ /// The digit character, which the chunk regex guarantees is category Nd.
+ /// The digit's value of 0-9.
+ private static int DigitValue(char digit) => CharUnicodeInfo.GetDecimalDigitValue(digit);
}