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