authorgravatar for jay@jayschwa.netJay Petacat <jay@jayschwa.net> 2020-10-06 23:06:19-04:00
committergravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2020-11-06 15:07:48-05:00
log030f00391af6a46697db8031d5bd58e78eca2454
treee3dc6611ea2662dc31e1495a5428ea03e3e7a9bb
parentd1b1f053b0fa8bf5bf716bae79fd656ce8aaec27

std: Introduce SemanticVersion data structure

This will parse, format, and compare version strings following the SemVer 2 specification. See: https://semver.org Updates #6466

2 files changed, 296 insertions(+), 0 deletions(-)

lib/std/SemanticVersion.zig created+295
...@@ -0,0 +1,295 @@
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2020 Zig Contributors
3// This file is part of [zig](https://ziglang.org/), which is MIT licensed.
4// The MIT license requires this copyright notice to be included in all copies
5// and substantial portions of the software.
6
7//! A software version formatted according to the Semantic Version 2 specification.
8//!
9//! See: https://semver.org
10
11const std = @import("std");
12const Version = @This();
13
14major: usize,
15minor: usize,
16patch: usize,
17pre: ?[]const u8 = null,
18build: ?[]const u8 = null,
19
20pub const Range = struct {
21 min: Version,
22 max: Version,
23
24 pub fn includesVersion(self: Range, ver: Version) bool {
25 if (self.min.order(ver) == .gt) return false;
26 if (self.max.order(ver) == .lt) return false;
27 return true;
28 }
29
30 /// Checks if system is guaranteed to be at least `version` or older than `version`.
31 /// Returns `null` if a runtime check is required.
32 pub fn isAtLeast(self: Range, ver: Version) ?bool {
33 if (self.min.order(ver) != .lt) return true;
34 if (self.max.order(ver) == .lt) return false;
35 return null;
36 }
37};
38
39pub fn order(lhs: Version, rhs: Version) std.math.Order {
40 if (lhs.major < rhs.major) return .lt;
41 if (lhs.major > rhs.major) return .gt;
42 if (lhs.minor < rhs.minor) return .lt;
43 if (lhs.minor > rhs.minor) return .gt;
44 if (lhs.patch < rhs.patch) return .lt;
45 if (lhs.patch > rhs.patch) return .gt;
46 if (lhs.pre != null and rhs.pre == null) return .lt;
47 if (lhs.pre == null and rhs.pre == null) return .eq;
48 if (lhs.pre == null and rhs.pre != null) return .gt;
49
50 // Iterate over pre-release identifiers until a difference is found.
51 var lhs_pre_it = std.mem.split(lhs.pre.?, ".");
52 var rhs_pre_it = std.mem.split(rhs.pre.?, ".");
53 while (true) {
54 const next_lid = lhs_pre_it.next();
55 const next_rid = rhs_pre_it.next();
56
57 // A larger set of pre-release fields has a higher precedence than a smaller set.
58 if (next_lid == null and next_rid != null) return .lt;
59 if (next_lid == null and next_rid == null) return .eq;
60 if (next_lid != null and next_rid == null) return .gt;
61
62 const lid = next_lid.?; // Left identifier
63 const rid = next_rid.?; // Right identifier
64
65 // Attempt to parse identifiers as numbers. Overflows are checked by parse.
66 const lnum: ?usize = std.fmt.parseUnsigned(usize, lid, 10) catch |err| switch (err) {
67 error.InvalidCharacter => null,
68 error.Overflow => unreachable,
69 };
70 const rnum: ?usize = std.fmt.parseUnsigned(usize, rid, 10) catch |err| switch (err) {
71 error.InvalidCharacter => null,
72 error.Overflow => unreachable,
73 };
74
75 // Numeric identifiers always have lower precedence than non-numeric identifiers.
76 if (lnum != null and rnum == null) return .lt;
77 if (lnum == null and rnum != null) return .gt;
78
79 // Identifiers consisting of only digits are compared numerically.
80 // Identifiers with letters or hyphens are compared lexically in ASCII sort order.
81 if (lnum != null and rnum != null) {
82 if (lnum.? < rnum.?) return .lt;
83 if (lnum.? > rnum.?) return .gt;
84 } else {
85 const ord = std.mem.order(u8, lid, rid);
86 if (ord != .eq) return ord;
87 }
88 }
89}
90
91pub fn parse(text: []const u8) !Version {
92 // Parse the required major, minor, and patch numbers.
93 const extra_index = std.mem.indexOfAny(u8, text, "-+");
94 const required = text[0..(extra_index orelse text.len)];
95 var it = std.mem.split(required, ".");
96 var ver = Version{
97 .major = try parseNum(it.next() orelse return error.InvalidVersion),
98 .minor = try parseNum(it.next() orelse return error.InvalidVersion),
99 .patch = try parseNum(it.next() orelse return error.InvalidVersion),
100 };
101 if (it.next() != null) return error.InvalidVersion;
102 if (extra_index == null) return ver;
103
104 // Slice optional pre-release or build metadata components.
105 const extra = text[extra_index.?..text.len];
106 if (extra[0] == '-') {
107 const build_index = std.mem.indexOfScalar(u8, extra, '+');
108 ver.pre = extra[1..(build_index orelse extra.len)];
109 if (build_index) |idx| ver.build = extra[(idx + 1)..];
110 } else {
111 ver.build = extra[1..];
112 }
113
114 // Check validity of optional pre-release identifiers.
115 // See: https://semver.org/#spec-item-9
116 if (ver.pre) |pre| {
117 it = std.mem.split(pre, ".");
118 while (it.next()) |id| {
119 // Identifiers MUST NOT be empty.
120 if (id.len == 0) return error.InvalidVersion;
121
122 // Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-].
123 for (id) |c| if (!std.ascii.isAlNum(c) and c != '-') return error.InvalidVersion;
124
125 // Numeric identifiers MUST NOT include leading zeroes.
126 const is_num = for (id) |c| {
127 if (!std.ascii.isDigit(c)) break false;
128 } else true;
129 if (is_num) _ = try parseNum(id);
130 }
131 }
132
133 // Check validity of optional build metadata identifiers.
134 // See: https://semver.org/#spec-item-10
135 if (ver.build) |build| {
136 it = std.mem.split(build, ".");
137 while (it.next()) |id| {
138 // Identifiers MUST NOT be empty.
139 if (id.len == 0) return error.InvalidVersion;
140
141 // Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-].
142 for (id) |c| if (!std.ascii.isAlNum(c) and c != '-') return error.InvalidVersion;
143 }
144 }
145
146 return ver;
147}
148
149fn parseNum(text: []const u8) !usize {
150 // Leading zeroes are not allowed.
151 if (text.len > 1 and text[0] == '0') return error.InvalidVersion;
152
153 return std.fmt.parseUnsigned(usize, text, 10) catch |err| switch (err) {
154 error.InvalidCharacter => return error.InvalidVersion,
155 else => |e| return e,
156 };
157}
158
159pub fn format(
160 self: Version,
161 comptime fmt: []const u8,
162 options: std.fmt.FormatOptions,
163 out_stream: anytype,
164) !void {
165 if (fmt.len != 0) @compileError("Unknown format string: '" ++ fmt ++ "'");
166 try std.fmt.format(out_stream, "{}.{}.{}", .{ self.major, self.minor, self.patch });
167 if (self.pre) |pre| try std.fmt.format(out_stream, "-{}", .{pre});
168 if (self.build) |build| try std.fmt.format(out_stream, "+{}", .{build});
169}
170
171const expect = std.testing.expect;
172const expectError = std.testing.expectError;
173
174test "SemanticVersion format" {
175 // Test vectors are from https://github.com/semver/semver.org/issues/59#issuecomment-390854010.
176
177 // Valid version strings should be accepted.
178 for ([_][]const u8{
179 "0.0.4",
180 "1.2.3",
181 "10.20.30",
182 "1.1.2-prerelease+meta",
183 "1.1.2+meta",
184 "1.1.2+meta-valid",
185 "1.0.0-alpha",
186 "1.0.0-beta",
187 "1.0.0-alpha.beta",
188 "1.0.0-alpha.beta.1",
189 "1.0.0-alpha.1",
190 "1.0.0-alpha0.valid",
191 "1.0.0-alpha.0valid",
192 "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay",
193 "1.0.0-rc.1+build.1",
194 "2.0.0-rc.1+build.123",
195 "1.2.3-beta",
196 "10.2.3-DEV-SNAPSHOT",
197 "1.2.3-SNAPSHOT-123",
198 "1.0.0",
199 "2.0.0",
200 "1.1.7",
201 "2.0.0+build.1848",
202 "2.0.1-alpha.1227",
203 "1.0.0-alpha+beta",
204 "1.2.3----RC-SNAPSHOT.12.9.1--.12+788",
205 "1.2.3----R-S.12.9.1--.12+meta",
206 "1.2.3----RC-SNAPSHOT.12.9.1--.12",
207 "1.0.0+0.build.1-rc.10000aaa-kk-0.1",
208 }) |valid| try testFmt(valid, "{}", .{try parse(valid)});
209
210 // Invalid version strings should be rejected.
211 for ([_][]const u8{
212 "",
213 "1",
214 "1.2",
215 "1.2.3-0123",
216 "1.2.3-0123.0123",
217 "1.1.2+.123",
218 "+invalid",
219 "-invalid",
220 "-invalid+invalid",
221 "-invalid.01",
222 "alpha",
223 "alpha.beta",
224 "alpha.beta.1",
225 "alpha.1",
226 "alpha+beta",
227 "alpha_beta",
228 "alpha.",
229 "alpha..",
230 "beta\\",
231 "1.0.0-alpha_beta",
232 "-alpha.",
233 "1.0.0-alpha..",
234 "1.0.0-alpha..1",
235 "1.0.0-alpha...1",
236 "1.0.0-alpha....1",
237 "1.0.0-alpha.....1",
238 "1.0.0-alpha......1",
239 "1.0.0-alpha.......1",
240 "01.1.1",
241 "1.01.1",
242 "1.1.01",
243 "1.2",
244 "1.2.3.DEV",
245 "1.2-SNAPSHOT",
246 "1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788",
247 "1.2-RC-SNAPSHOT",
248 "-1.0.3-gamma+b7718",
249 "+justmeta",
250 "9.8.7+meta+meta",
251 "9.8.7-whatever+meta+meta",
252 }) |invalid| expectError(error.InvalidVersion, parse(invalid));
253
254 // Valid version string that may overflow.
255 const big_valid = "99999999999999999999999.999999999999999999.99999999999999999";
256 if (parse(big_valid)) |ver| try testFmt(big_valid, "{}", .{ver}) else |err| expect(err == error.Overflow);
257
258 // Invalid version string that may overflow.
259 const big_invalid = "99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12";
260 if (parse(big_invalid)) |ver| std.debug.panic("expected error, found {}", .{ver}) else |err| {}
261}
262
263test "SemanticVersion precedence" {
264 // SemVer 2 spec 11.2 example: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1.
265 expect(order(try parse("1.0.0"), try parse("2.0.0")) == .lt);
266 expect(order(try parse("2.0.0"), try parse("2.1.0")) == .lt);
267 expect(order(try parse("2.1.0"), try parse("2.1.1")) == .lt);
268
269 // SemVer 2 spec 11.3 example: 1.0.0-alpha < 1.0.0.
270 expect(order(try parse("1.0.0-alpha"), try parse("1.0.0")) == .lt);
271
272 // SemVer 2 spec 11.4 example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta <
273 // 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0.
274 expect(order(try parse("1.0.0-alpha"), try parse("1.0.0-alpha.1")) == .lt);
275 expect(order(try parse("1.0.0-alpha.1"), try parse("1.0.0-alpha.beta")) == .lt);
276 expect(order(try parse("1.0.0-alpha.beta"), try parse("1.0.0-beta")) == .lt);
277 expect(order(try parse("1.0.0-beta"), try parse("1.0.0-beta.2")) == .lt);
278 expect(order(try parse("1.0.0-beta.2"), try parse("1.0.0-beta.11")) == .lt);
279 expect(order(try parse("1.0.0-beta.11"), try parse("1.0.0-rc.1")) == .lt);
280 expect(order(try parse("1.0.0-rc.1"), try parse("1.0.0")) == .lt);
281}
282
283// This is copy-pasted from fmt.zig since it is not public.
284fn testFmt(expected: []const u8, comptime template: []const u8, args: anytype) !void {
285 var buf: [100]u8 = undefined;
286 const result = try std.fmt.bufPrint(buf[0..], template, args);
287 if (std.mem.eql(u8, result, expected)) return;
288
289 std.debug.warn("\n====== expected this output: =========\n", .{});
290 std.debug.warn("{}", .{expected});
291 std.debug.warn("\n======== instead found this: =========\n", .{});
292 std.debug.warn("{}", .{result});
293 std.debug.warn("\n======================================\n", .{});
294 return error.TestFailed;
295}
lib/std/std.zig+1
...@@ -32,6 +32,7 @@ pub const PriorityQueue = @import("priority_queue.zig").PriorityQueue;...@@ -32,6 +32,7 @@ pub const PriorityQueue = @import("priority_queue.zig").PriorityQueue;
32pub const Progress = @import("progress.zig").Progress;32pub const Progress = @import("progress.zig").Progress;
33pub const ResetEvent = @import("reset_event.zig").ResetEvent;33pub const ResetEvent = @import("reset_event.zig").ResetEvent;
34pub const SegmentedList = @import("segmented_list.zig").SegmentedList;34pub const SegmentedList = @import("segmented_list.zig").SegmentedList;
35pub const SemanticVersion = @import("SemanticVersion.zig");
35pub const SinglyLinkedList = @import("linked_list.zig").SinglyLinkedList;36pub const SinglyLinkedList = @import("linked_list.zig").SinglyLinkedList;
36pub const SpinLock = @import("spinlock.zig").SpinLock;37pub const SpinLock = @import("spinlock.zig").SpinLock;
37pub const StringHashMap = hash_map.StringHashMap;38pub const StringHashMap = hash_map.StringHashMap;