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 }