|
| 1 | +namespace PkgDiff.Application.Versioning; |
| 2 | + |
| 3 | +// A Maven Central version ordered as Maven's ComparableVersion does it: |
| 4 | +// alpha < beta < milestone < rc < snapshot < release (ga, final) < sp < any other qualifier |
| 5 | +public sealed class MavenVersion : IComparable<MavenVersion>, IComparable |
| 6 | +{ |
| 7 | + private const int MaxLength = 100; |
| 8 | + |
| 9 | + private static readonly string[] Qualifiers = { "alpha", "beta", "milestone", "rc", "snapshot", "", "sp" }; |
| 10 | + |
| 11 | + private static readonly Dictionary<string, string> Aliases = new(StringComparer.Ordinal) |
| 12 | + { |
| 13 | + ["ga"] = "", |
| 14 | + ["final"] = "", |
| 15 | + ["release"] = "", |
| 16 | + ["cr"] = "rc" |
| 17 | + }; |
| 18 | + |
| 19 | + private static readonly HashSet<string> PrereleaseWords = new(StringComparer.Ordinal) |
| 20 | + { |
| 21 | + "alpha", "beta", "milestone", "rc", "snapshot", "preview", "pre", "ea", "dev", "incubating" |
| 22 | + }; |
| 23 | + |
| 24 | + private readonly ListItem _items; |
| 25 | + |
| 26 | + private MavenVersion(ListItem items, bool isPrerelease) |
| 27 | + { |
| 28 | + _items = items; |
| 29 | + IsPrerelease = isPrerelease; |
| 30 | + } |
| 31 | + |
| 32 | + public bool IsPrerelease { get; } |
| 33 | + |
| 34 | + public static bool TryParse(string? value, out MavenVersion version) |
| 35 | + { |
| 36 | + version = null!; |
| 37 | + if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength) |
| 38 | + return false; |
| 39 | + |
| 40 | + value = value.Trim(); |
| 41 | + if (!char.IsAsciiDigit(value[0]) || value.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('.' or '-' or '_' or '+'))) |
| 42 | + return false; |
| 43 | + |
| 44 | + var items = Parse(value.ToLowerInvariant(), out var isPrerelease); |
| 45 | + version = new MavenVersion(items, isPrerelease); |
| 46 | + return true; |
| 47 | + } |
| 48 | + |
| 49 | + public int CompareTo(MavenVersion? other) => other is null ? 1 : _items.CompareTo(other._items); |
| 50 | + |
| 51 | + public int CompareTo(object? obj) => CompareTo(obj as MavenVersion); |
| 52 | + |
| 53 | + private static ListItem Parse(string version, out bool isPrerelease) |
| 54 | + { |
| 55 | + isPrerelease = false; |
| 56 | + var root = new ListItem(); |
| 57 | + var list = root; |
| 58 | + var stack = new Stack<ListItem>(); |
| 59 | + stack.Push(list); |
| 60 | + |
| 61 | + var isDigit = false; |
| 62 | + var start = 0; |
| 63 | + |
| 64 | + for (var i = 0; i < version.Length; i++) |
| 65 | + { |
| 66 | + var c = version[i]; |
| 67 | + if (c == '.') |
| 68 | + { |
| 69 | + list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease)); |
| 70 | + start = i + 1; |
| 71 | + } |
| 72 | + else if (c is '-' or '_' or '+') |
| 73 | + { |
| 74 | + list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease)); |
| 75 | + start = i + 1; |
| 76 | + list.Add(list = new ListItem()); |
| 77 | + stack.Push(list); |
| 78 | + } |
| 79 | + else if (char.IsAsciiDigit(c)) |
| 80 | + { |
| 81 | + if (!isDigit && i > start) |
| 82 | + { |
| 83 | + // "m1" is milestone 1: a single letter right before a digit is a short qualifier |
| 84 | + list.Add(StringItem.Of(version[start..i], followedByDigit: true, ref isPrerelease)); |
| 85 | + start = i; |
| 86 | + list.Add(list = new ListItem()); |
| 87 | + stack.Push(list); |
| 88 | + } |
| 89 | + isDigit = true; |
| 90 | + } |
| 91 | + else |
| 92 | + { |
| 93 | + if (isDigit && i > start) |
| 94 | + { |
| 95 | + list.Add(ParseItem(true, version[start..i], ref isPrerelease)); |
| 96 | + start = i; |
| 97 | + list.Add(list = new ListItem()); |
| 98 | + stack.Push(list); |
| 99 | + } |
| 100 | + isDigit = false; |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + if (version.Length > start) |
| 105 | + list.Add(ParseItem(isDigit, version[start..], ref isPrerelease)); |
| 106 | + |
| 107 | + while (stack.Count > 0) |
| 108 | + stack.Pop().Normalize(); |
| 109 | + |
| 110 | + return root; |
| 111 | + } |
| 112 | + |
| 113 | + private static Item ParseItem(bool isDigit, string text, ref bool isPrerelease) |
| 114 | + { |
| 115 | + if (isDigit) |
| 116 | + { |
| 117 | + var trimmed = text.TrimStart('0'); |
| 118 | + return new IntItem(trimmed.Length == 0 ? "0" : trimmed); |
| 119 | + } |
| 120 | + return StringItem.Of(text, followedByDigit: false, ref isPrerelease); |
| 121 | + } |
| 122 | + |
| 123 | + private abstract class Item |
| 124 | + { |
| 125 | + public abstract bool IsNull { get; } |
| 126 | + |
| 127 | + public abstract int CompareTo(Item? other); |
| 128 | + } |
| 129 | + |
| 130 | + private sealed class IntItem : Item |
| 131 | + { |
| 132 | + public static readonly IntItem Zero = new("0"); |
| 133 | + |
| 134 | + // Digits without leading zeros, so longer means larger and equal length compares as text |
| 135 | + private readonly string _digits; |
| 136 | + |
| 137 | + public IntItem(string digits) => _digits = digits; |
| 138 | + |
| 139 | + public override bool IsNull => _digits == "0"; |
| 140 | + |
| 141 | + public override int CompareTo(Item? other) => other switch |
| 142 | + { |
| 143 | + null => IsNull ? 0 : 1, |
| 144 | + IntItem number => _digits.Length != number._digits.Length |
| 145 | + ? _digits.Length.CompareTo(number._digits.Length) |
| 146 | + : string.CompareOrdinal(_digits, number._digits), |
| 147 | + _ => 1 |
| 148 | + }; |
| 149 | + } |
| 150 | + |
| 151 | + private sealed class StringItem : Item |
| 152 | + { |
| 153 | + private readonly string _value; |
| 154 | + |
| 155 | + private StringItem(string value) => _value = value; |
| 156 | + |
| 157 | + public static StringItem Of(string value, bool followedByDigit, ref bool isPrerelease) |
| 158 | + { |
| 159 | + if (followedByDigit && value.Length == 1) |
| 160 | + { |
| 161 | + value = value[0] switch |
| 162 | + { |
| 163 | + 'a' => "alpha", |
| 164 | + 'b' => "beta", |
| 165 | + 'm' => "milestone", |
| 166 | + _ => value |
| 167 | + }; |
| 168 | + } |
| 169 | + |
| 170 | + if (Aliases.TryGetValue(value, out var alias)) |
| 171 | + value = alias; |
| 172 | + |
| 173 | + if (PrereleaseWords.Contains(value)) |
| 174 | + isPrerelease = true; |
| 175 | + |
| 176 | + return new StringItem(value); |
| 177 | + } |
| 178 | + |
| 179 | + public override bool IsNull => Comparable(_value) == Comparable(string.Empty); |
| 180 | + |
| 181 | + public override int CompareTo(Item? other) => other switch |
| 182 | + { |
| 183 | + null => string.CompareOrdinal(Comparable(_value), Comparable(string.Empty)), |
| 184 | + StringItem text => string.CompareOrdinal(Comparable(_value), Comparable(text._value)), |
| 185 | + _ => -1 |
| 186 | + }; |
| 187 | + |
| 188 | + // Known qualifiers by their rank; any other comes after all of them, in text order |
| 189 | + private static string Comparable(string qualifier) |
| 190 | + { |
| 191 | + var index = Array.IndexOf(Qualifiers, qualifier); |
| 192 | + return index >= 0 ? index.ToString() : $"{Qualifiers.Length}-{qualifier}"; |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + private sealed class ListItem : Item |
| 197 | + { |
| 198 | + private readonly List<Item> _items = new(); |
| 199 | + |
| 200 | + public void Add(Item item) => _items.Add(item); |
| 201 | + |
| 202 | + public override bool IsNull => _items.Count == 0; |
| 203 | + |
| 204 | + // 1.0.0 is 1, and 1-ga is 1: trailing zeros and release words do not count |
| 205 | + public void Normalize() |
| 206 | + { |
| 207 | + for (var i = _items.Count - 1; i >= 0; i--) |
| 208 | + { |
| 209 | + var item = _items[i]; |
| 210 | + if (item.IsNull) |
| 211 | + _items.RemoveAt(i); |
| 212 | + else if (item is not ListItem) |
| 213 | + break; |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + public override int CompareTo(Item? other) |
| 218 | + { |
| 219 | + switch (other) |
| 220 | + { |
| 221 | + case null: |
| 222 | + return _items.Count == 0 ? 0 : _items[0].CompareTo(null); |
| 223 | + case IntItem: |
| 224 | + return -1; |
| 225 | + case StringItem: |
| 226 | + return 1; |
| 227 | + } |
| 228 | + |
| 229 | + var right = ((ListItem)other)._items; |
| 230 | + for (var i = 0; i < Math.Max(_items.Count, right.Count); i++) |
| 231 | + { |
| 232 | + var l = i < _items.Count ? _items[i] : null; |
| 233 | + var r = i < right.Count ? right[i] : null; |
| 234 | + var result = l is null |
| 235 | + ? (r is null ? 0 : -r.CompareTo(null)) |
| 236 | + : l.CompareTo(r); |
| 237 | + if (result != 0) |
| 238 | + return result; |
| 239 | + } |
| 240 | + return 0; |
| 241 | + } |
| 242 | + } |
| 243 | +} |
0 commit comments