| | | 1 | | using System; |
| | | 2 | | using System.Linq; |
| | | 3 | | using System.Text; |
| | | 4 | | |
| | | 5 | | namespace AmbientServices.Utilities; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// A static partial class that extends <see cref="string"/>. |
| | | 9 | | /// </summary> |
| | | 10 | | /// <remarks> |
| | | 11 | | /// <pitch>The normalization engine behind natural string comparison in <see cref="AmbientServices.Extensions.StringExte |
| | | 12 | | /// <pledge>Given the same <c>maxDigits</c> (at least the longest digit run in either input), ordinal comparison of two |
| | | 13 | | /// <plan>A single compiled regex classifies each numeric token — period- or dash-separated sequences (versions, dates; |
| | | 14 | | /// </remarks> |
| | | 15 | | internal static partial class StringUtilities |
| | | 16 | | { |
| | 2 | 17 | | private static readonly char[] DecimalPointCharArray = ".,".ToCharArray(); |
| | 2 | 18 | | private static readonly char[] NumberSeparatorCharArray = ".,-".ToCharArray(); |
| | 2 | 19 | | private static readonly System.Text.RegularExpressions.Regex NumberRegex = new( |
| | 2 | 20 | | @"(?<ps>(?:-?\d+)\.(?:(?:\d+)\.)+(?:\d+))" + // finds a sequence of numbers separated by periods, such as 202 |
| | 2 | 21 | | @"|(?<ds>(?:-?\d+)-(?:(?:\d+)-)+(?:\d+))" + // finds a sequence of numbers separated by dashes, such as 2020 |
| | 2 | 22 | | @"|(?<nr>(?<![0-9])(?:-(?:\d*)\.\d+))" + // finds a negative real |
| | 2 | 23 | | @"|(?<ni>(?<![0-9])(?:-(?:\d+)))" + // finds a negative integer |
| | 2 | 24 | | @"|(?<pr>(?<![-.,]\d*)(?:(?:\d*)\.\d+))" + // finds a positive real |
| | 2 | 25 | | @"|(?<pi>(?<![-.,]\d*)(?:\d+))", // finds a positive integer |
| | 2 | 26 | | System.Text.RegularExpressions.RegexOptions.Compiled); |
| | | 27 | | internal static string NormalizeStringWithNumberSequences(string str, int maxDigits) |
| | | 28 | | { |
| | 2 | 29 | | str = NumberRegex.Replace(str, |
| | 2 | 30 | | delegate (System.Text.RegularExpressions.Match m) |
| | 2 | 31 | | { |
| | 2 | 32 | | int matchGroup = 1; |
| | 2 | 33 | | for (; matchGroup < m.Groups.Count; ++matchGroup) |
| | 2 | 34 | | { |
| | 2 | 35 | | if (m.Groups[matchGroup].Captures.Count > 0) break; |
| | 2 | 36 | | } |
| | 2 | 37 | | // I don't think this should ever happen, but just in case... |
| | 2 | 38 | | if (matchGroup >= m.Groups.Count) return m.Value; |
| | 2 | 39 | | int prefixIndex = m.Index - 1; |
| | 2 | 40 | | int decimalPointIndex; |
| | 2 | 41 | | string wholePart; |
| | 2 | 42 | | string fractionPart; |
| | 2 | 43 | | string[] numberParts; |
| | 2 | 44 | | // Note that the use of 1 and 4 here is to be sure that negatives sort before positives. 1 is like a si |
| | 2 | 45 | | switch (matchGroup) |
| | 2 | 46 | | { |
| | 2 | 47 | | case 1: // ps: period sequence |
| | 2 | 48 | | numberParts = m.Value.Split(NumberSeparatorCharArray); |
| | 2 | 49 | | return (numberParts[0].Length == 0 && m.Value[0] == '-') |
| | 2 | 50 | | ? NegativePartTransform("1", numberParts[0].PadLeft(maxDigits, '0')) + "." + string.Join(".", nu |
| | 2 | 51 | | : string.Join(".", numberParts.Select(s => "4" + s.PadLeft(maxDigits, '0'))); |
| | 2 | 52 | | case 2: // ds: dash sequence |
| | 2 | 53 | | numberParts = m.Value.Split(NumberSeparatorCharArray); |
| | 2 | 54 | | return (numberParts[0].Length == 0 && m.Value[0] == '-') |
| | 2 | 55 | | ? NegativePartTransform("1", numberParts[0].PadLeft(maxDigits, '0')) + "-" + string.Join("-", nu |
| | 2 | 56 | | : string.Join("-", numberParts.Select(s => "4" + s.PadLeft(maxDigits, '0'))); |
| | 2 | 57 | | case 3: // nr: negative real |
| | 2 | 58 | | decimalPointIndex = m.Value.IndexOfAny(DecimalPointCharArray, 1); |
| | 2 | 59 | | System.Diagnostics.Debug.Assert(decimalPointIndex > 0); |
| | 2 | 60 | | wholePart = NegativePartTransform("1", m.Value.Substring(1, decimalPointIndex - 1).PadLeft(maxDi |
| | 2 | 61 | | fractionPart = NegativePartTransform("4", m.Value.Substring(decimalPointIndex + 1, m.Value.Lengt |
| | 2 | 62 | | return wholePart + fractionPart; |
| | 2 | 63 | | case 4: // ni: negative integer |
| | 2 | 64 | | return NegativePartTransform("1", m.Value.Substring(1).PadLeft(maxDigits, '0')); |
| | 2 | 65 | | case 5: // pr: positive real |
| | 2 | 66 | | decimalPointIndex = m.Value.IndexOfAny(DecimalPointCharArray, 0); |
| | 2 | 67 | | System.Diagnostics.Debug.Assert(decimalPointIndex >= 0); |
| | 2 | 68 | | wholePart = "4" + m.Value.Substring(0, decimalPointIndex).PadLeft(maxDigits, '0'); |
| | 2 | 69 | | fractionPart = "4" + m.Value.Substring(decimalPointIndex + 1, m.Value.Length - decimalPointIndex |
| | 2 | 70 | | return wholePart + fractionPart; |
| | 2 | 71 | | case 6: // pi: positive integer |
| | 2 | 72 | | return "4" + m.Value.PadLeft(maxDigits, '0'); |
| | 2 | 73 | | default: |
| | 2 | 74 | | // this should also never happen, but just in case... |
| | 0 | 75 | | throw new InvalidOperationException("The match group number was not expected--the regex must hav |
| | 2 | 76 | | } |
| | 2 | 77 | | }); |
| | 2 | 78 | | return str; |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | private static string NegativePartTransform(string prefix, string str) |
| | | 82 | | { |
| | | 83 | | System.Diagnostics.Debug.Assert(str[0] != '-'); |
| | | 84 | | // use 1 as the 'negative prefix' because it should sort before the 'positive prefix' of 4 |
| | 2 | 85 | | StringBuilder builder = new(prefix); |
| | 2 | 86 | | for (int off = 0; off < str.Length; ++off) |
| | | 87 | | { |
| | 2 | 88 | | char c = str[off]; |
| | | 89 | | System.Diagnostics.Debug.Assert(c >= '0' && c <= '9'); |
| | 2 | 90 | | builder.Append((char)('0' + (9 - (c - '0')))); |
| | | 91 | | } |
| | 2 | 92 | | return builder.ToString(); |
| | | 93 | | } |
| | | 94 | | } |