1 module dsemver.semver; 2 3 import std.ascii : isAlpha, isDigit; 4 import std.array : array, empty, front; 5 import std.typecons : nullable, Nullable; 6 import std.algorithm.iteration : each, filter, map, splitter; 7 import std.algorithm.searching : all, countUntil; 8 import std.conv : to; 9 import std.format : format; 10 import std.exception : enforce, basicExceptionCtors, assertThrown, 11 assertNotThrown; 12 import std.utf : byChar, byUTF; 13 import std.range : popFront; 14 15 @safe: 16 17 struct SemVer { 18 @safe: 19 20 uint major; 21 uint minor; 22 uint patch; 23 24 string[] preRelease; 25 string[] buildIdentifier; 26 27 static immutable(SemVer) MinRelease = SemVer(0, 0, 0); 28 static immutable(SemVer) MaxRelease = SemVer(uint.max, uint.max, uint.max); 29 30 bool opEquals(const(SemVer) other) const nothrow pure { 31 return compare(this, other) == 0; 32 } 33 34 int opCmp(const(SemVer) other) const nothrow pure { 35 return compare(this, other); 36 } 37 38 size_t toHash() const nothrow @nogc pure { 39 size_t hash = this.major.hashOf(); 40 hash = this.minor.hashOf(hash); 41 hash = this.patch.hashOf(hash); 42 this.preRelease.each!(it => hash = it.hashOf(hash)); 43 this.buildIdentifier.each!(it => hash = it.hashOf(hash)); 44 return hash; 45 } 46 47 @property SemVer dup() const pure { 48 auto ret = SemVer(this.major, this.minor, this.patch, 49 this.preRelease.dup(), this.buildIdentifier.dup()); 50 return ret; 51 } 52 53 static SemVer max() pure { 54 return SemVer(uint.max, uint.max, uint.max); 55 } 56 57 static SemVer min() pure { 58 return SemVer(uint.min, uint.min, uint.min); 59 } 60 61 string toString() const @safe pure { 62 import std.array : appender, empty; 63 import std.format : format; 64 string ret = format("%s.%s.%s", this.major, this.minor, this.patch); 65 if(!this.preRelease.empty) { 66 ret ~= format("-%-(%s.%)", this.preRelease); 67 } 68 if(!this.buildIdentifier.empty) { 69 ret ~= format("+%-(%s.%)", this.buildIdentifier); 70 } 71 return ret; 72 } 73 } 74 75 int compare(const(SemVer) a, const(SemVer) b) nothrow pure { 76 if(a.major != b.major) { 77 return a.major < b.major ? -1 : 1; 78 } 79 80 if(a.minor != b.minor) { 81 return a.minor < b.minor ? -1 : 1; 82 } 83 84 if(a.patch != b.patch) { 85 return a.patch < b.patch ? -1 : 1; 86 } 87 88 if(a.preRelease.empty != b.preRelease.empty) { 89 return a.preRelease.empty ? 1 : -1; 90 } 91 92 size_t idx; 93 while(idx < a.preRelease.length && idx < b.preRelease.length) { 94 string aStr = a.preRelease[idx]; 95 string bStr = b.preRelease[idx]; 96 Nullable!uint aNumN = isAllNum(aStr); 97 Nullable!uint bNumN = isAllNum(bStr); 98 if(!aNumN.isNull() && !bNumN.isNull()) { 99 uint aNum = aNumN.get(); 100 uint bNum = bNumN.get(); 101 102 if(aNum != bNum) { 103 return aNum < bNum ? -1 : 1; 104 } 105 } else if(aStr != bStr) { 106 return aStr < bStr ? -1 : 1; 107 } 108 ++idx; 109 } 110 111 if(idx == a.preRelease.length && idx == b.preRelease.length) { 112 return 0; 113 } 114 115 return idx < a.preRelease.length ? 1 : -1; 116 } 117 118 Nullable!uint isAllNum(string s) nothrow pure { 119 import std.utf : byUTF; 120 import std.ascii : isDigit; 121 import std.algorithm.searching : all; 122 import std.conv : to, ConvException; 123 124 const bool allNum = s.byUTF!char().all!isDigit(); 125 126 if(allNum) { 127 try { 128 return nullable(to!uint(s)); 129 } catch(Exception e) { 130 assert(false, s); 131 } 132 } 133 return Nullable!(uint).init; 134 } 135 136 unittest { 137 import std.format : format; 138 auto i = isAllNum("hello world"); 139 assert(i.isNull()); 140 141 i = isAllNum("12354"); 142 assert(!i.isNull()); 143 assert(i.get() == 12354); 144 145 i = isAllNum("0002354"); 146 assert(!i.isNull()); 147 assert(i.get() == 2354); 148 } 149 150 SemVer parseSemVer(string input) { 151 SemVer ret; 152 153 char[] inputRange = to!(char[])(input); 154 155 if(!inputRange.empty && inputRange.front == 'v') { 156 inputRange.popFront(); 157 } 158 159 ret.major = splitOutNumber!isDot("Major", "first", inputRange); 160 ret.minor = splitOutNumber!isDot("Minor", "second", inputRange); 161 ret.patch = toNum("Patch", dropUntilPredOrEmpty!isPlusOrMinus(inputRange)); 162 if(!inputRange.empty && inputRange[0].isMinus()) { 163 inputRange.popFront(); 164 ret.preRelease = splitter(dropUntilPredOrEmpty!isPlus(inputRange), '.') 165 .map!(it => checkNotEmpty(it)) 166 .map!(it => checkASCII(it)) 167 .map!(it => to!string(it)) 168 .array; 169 } 170 if(!inputRange.empty) { 171 enforce!InvalidSeperator(inputRange[0] == '+', 172 format("Expected a '+' got '%s'", inputRange[0])); 173 inputRange.popFront(); 174 ret.buildIdentifier = 175 splitter(dropUntilPredOrEmpty!isFalse(inputRange), '.') 176 .map!(it => checkNotEmpty(it)) 177 .map!(it => checkASCII(it)) 178 .map!(it => to!string(it)) 179 .array; 180 } 181 enforce!InputNotEmpty(inputRange.empty, 182 format("Surprisingly input '%s' left", inputRange)); 183 return ret; 184 } 185 186 char[] checkNotEmpty(char[] cs) { 187 enforce!EmptyIdentifier(!cs.empty, 188 "Build or prerelease identifier must not be empty"); 189 return cs; 190 } 191 192 char[] checkASCII(char[] cs) { 193 foreach(it; cs.byUTF!char()) { 194 enforce!NonAsciiChar(isDigit(it) || isAlpha(it) || it == '-', format( 195 "Non ASCII character '%s' surprisingly found input '%s'", 196 it, cs 197 )); 198 } 199 return cs; 200 } 201 202 uint toNum(string numName, char[] input) { 203 enforce!OnlyDigitAllowed(all!(isDigit)(input.byUTF!char()), 204 format("%s range must solely consist of digits not '%s'", 205 numName, input)); 206 return to!uint(input); 207 } 208 209 uint splitOutNumber(alias pred)(const string numName, const string dotName, 210 ref char[] input) 211 { 212 const ptrdiff_t dot = input.byUTF!char().countUntil!pred(); 213 enforce!InvalidSeperator(dot != -1, 214 format("Couldn't find the %s dot in '%s'", dotName, input)); 215 char[] num = input[0 .. dot]; 216 const uint ret = toNum(numName, num); 217 enforce!EmptyInput(input.length > dot + 1, 218 format("Input '%s' ended surprisingly after %s version", 219 input, numName)); 220 input = input[dot + 1 .. $]; 221 return ret; 222 } 223 224 char[] dropUntilPredOrEmpty(alias pred)(ref char[] input) @nogc nothrow pure { 225 size_t pos; 226 while(pos < input.length && !pred(input[pos])) { 227 ++pos; 228 } 229 char[] ret = input[0 .. pos]; 230 input = input[pos .. $]; 231 return ret; 232 } 233 234 bool isFalse(char c) @nogc nothrow pure { 235 return false; 236 } 237 238 bool isDot(char c) @nogc nothrow pure { 239 return c == '.'; 240 } 241 242 bool isMinus(char c) @nogc nothrow pure { 243 return c == '-'; 244 } 245 246 bool isPlus(char c) @nogc nothrow pure { 247 return c == '+'; 248 } 249 250 bool isPlusOrMinus(char c) @nogc nothrow pure { 251 return isPlus(c) || isMinus(c); 252 } 253 254 class SemVerParseException : Exception { 255 mixin basicExceptionCtors; 256 } 257 258 class NonAsciiChar : SemVerParseException { 259 mixin basicExceptionCtors; 260 } 261 262 class EmptyInput : SemVerParseException { 263 mixin basicExceptionCtors; 264 } 265 266 class OnlyDigitAllowed : SemVerParseException { 267 mixin basicExceptionCtors; 268 } 269 270 class InvalidSeperator : SemVerParseException { 271 mixin basicExceptionCtors; 272 } 273 274 class InputNotEmpty : SemVerParseException { 275 mixin basicExceptionCtors; 276 } 277 278 class EmptyIdentifier : SemVerParseException { 279 mixin basicExceptionCtors; 280 } 281 282 private struct StrSV { 283 string str; 284 SemVer sv; 285 } 286 287 unittest { 288 StrSV[] tests = [ 289 StrSV("0.0.4", SemVer(0,0,4)), 290 StrSV("1.2.3", SemVer(1,2,3)), 291 StrSV("10.20.30", SemVer(10,20,30)), 292 StrSV("1.1.2-prerelease+meta", SemVer(1,1,2,["prerelease"], ["meta"])), 293 StrSV("1.1.2+meta", SemVer(1,1,2,[],["meta"])), 294 StrSV("1.0.0-alpha", SemVer(1,0,0,["alpha"],[])), 295 StrSV("1.0.0-beta", SemVer(1,0,0,["beta"],[])), 296 StrSV("1.0.0-alpha.beta", SemVer(1,0,0,["alpha", "beta"],[])), 297 StrSV("1.0.0-alpha.beta.1", SemVer(1,0,0,["alpha", "beta", "1"],[])), 298 StrSV("1.0.0-alpha.1", SemVer(1,0,0,["alpha", "1"],[])), 299 StrSV("1.0.0-alpha0.valid", SemVer(1,0,0,["alpha0", "valid"],[])), 300 StrSV("1.0.0-alpha.0valid", SemVer(1,0,0,["alpha", "0valid"],[])), 301 StrSV("1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", 302 SemVer(1,0,0,["alpha-a", "b-c-somethinglong"], 303 ["build","1-aef","1-its-okay"])), 304 StrSV("1.0.0-rc.1+build.1", SemVer(1,0,0,["rc", "1"],["build","1"])), 305 StrSV("2.0.0-rc.1+build.123", SemVer(2,0,0,["rc", "1"],["build", "123"])), 306 StrSV("1.2.3-beta", SemVer(1,2,3,["beta"],[])), 307 StrSV("10.2.3-DEV-SNAPSHOT", SemVer(10,2,3,["DEV-SNAPSHOT"],[])), 308 StrSV("1.2.3-SNAPSHOT-123", SemVer(1,2,3,["SNAPSHOT-123"],[])), 309 StrSV("1.0.0", SemVer(1,0,0,[],[])), 310 StrSV("2.0.0", SemVer(2,0,0,[],[])), 311 StrSV("1.1.7", SemVer(1,1,7,[],[])), 312 StrSV("2.0.0+build.1848", SemVer(2,0,0,[],["build","1848"])), 313 StrSV("2.0.1-alpha.1227", SemVer(2,0,1,["alpha", "1227"],[])), 314 StrSV("1.0.0-alpha+beta", SemVer(1,0,0,["alpha"],["beta"])), 315 StrSV("1.0.0-0A.is.legal", SemVer(1,0,0,["0A", "is", "legal"],[])), 316 StrSV("1.1.2+meta-valid", SemVer(1,1,2, [], ["meta-valid"])), 317 318 StrSV("v0.0.4", SemVer(0,0,4)), 319 StrSV("v1.2.3", SemVer(1,2,3)), 320 StrSV("v10.20.30", SemVer(10,20,30)), 321 StrSV("v1.1.2-prerelease+meta", SemVer(1,1,2,["prerelease"], ["meta"])), 322 StrSV("v1.1.2+meta", SemVer(1,1,2,[],["meta"])), 323 StrSV("v1.0.0-alpha", SemVer(1,0,0,["alpha"],[])), 324 StrSV("v1.0.0-beta", SemVer(1,0,0,["beta"],[])), 325 StrSV("v1.0.0-alpha.beta", SemVer(1,0,0,["alpha", "beta"],[])), 326 StrSV("v1.0.0-alpha.beta.1", SemVer(1,0,0,["alpha", "beta", "1"],[])), 327 StrSV("v1.0.0-alpha.1", SemVer(1,0,0,["alpha", "1"],[])), 328 StrSV("v1.0.0-alpha0.valid", SemVer(1,0,0,["alpha0", "valid"],[])), 329 StrSV("v1.0.0-alpha.0valid", SemVer(1,0,0,["alpha", "0valid"],[])), 330 StrSV("v1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", 331 SemVer(1,0,0,["alpha-a", "b-c-somethinglong"], 332 ["build","1-aef","1-its-okay"])), 333 StrSV("v1.0.0-rc.1+build.1", SemVer(1,0,0,["rc", "1"],["build","1"])), 334 StrSV("v2.0.0-rc.1+build.123", SemVer(2,0,0,["rc", "1"],["build", "123"])), 335 StrSV("v1.2.3-beta", SemVer(1,2,3,["beta"],[])), 336 StrSV("v10.2.3-DEV-SNAPSHOT", SemVer(10,2,3,["DEV-SNAPSHOT"],[])), 337 StrSV("v1.2.3-SNAPSHOT-123", SemVer(1,2,3,["SNAPSHOT-123"],[])), 338 StrSV("v1.0.0", SemVer(1,0,0,[],[])), 339 StrSV("v2.0.0", SemVer(2,0,0,[],[])), 340 StrSV("v1.1.7", SemVer(1,1,7,[],[])), 341 StrSV("v2.0.0+build.1848", SemVer(2,0,0,[],["build","1848"])), 342 StrSV("v2.0.1-alpha.1227", SemVer(2,0,1,["alpha", "1227"],[])), 343 StrSV("v1.0.0-alpha+beta", SemVer(1,0,0,["alpha"],["beta"])), 344 StrSV("v1.0.0-0A.is.legal", SemVer(1,0,0,["0A", "is", "legal"],[])), 345 StrSV("v1.1.2+meta-valid", SemVer(1,1,2, [], ["meta-valid"])) 346 ]; 347 348 foreach(test; tests) { 349 SemVer sv = assertNotThrown(parseSemVer(test.str), 350 format("An exception was thrown while parsing '%s'", test.str)); 351 assert(sv == test.sv, format("\ngot: %s\nexp: %s", sv, test.sv)); 352 } 353 } 354 355 unittest { 356 assertThrown!InvalidSeperator(parseSemVer("Hello World")); 357 assertThrown!OnlyDigitAllowed(parseSemVer("Hello World.")); 358 assertThrown!OnlyDigitAllowed(parseSemVer("1.2.332a")); 359 assertThrown!NonAsciiChar(parseSemVer("1.2.3+ßßßßääü")); 360 assertThrown!EmptyInput(parseSemVer("1.2.")); 361 assertThrown!EmptyInput(parseSemVer("1.")); 362 assertThrown!EmptyIdentifier(parseSemVer("2.0.1-alpha.1227..")); 363 assertThrown!EmptyIdentifier(parseSemVer("2.0.1+alpha.1227..")); 364 }