//! A software version formatted according to the Semantic Versioning 2.0.0 specification. //! //! See: https://semver.org const std = @import("std"); const Version = @This(); major: usize, minor: usize, patch: usize, pre: ?[]const u8 = null, build: ?[]const u8 = null, pub const Range = struct { min: Version, max: Version, pub fn includesVersion(self: Range, ver: Version) bool { if (self.min.order(ver) == .gt) return false; if (self.max.order(ver) == .lt) return false; return true; } /// Checks if system is guaranteed to be at least `version` or older than `version`. /// Returns `null` if a runtime check is required. pub fn isAtLeast(self: Range, ver: Version) ?bool { if (self.min.order(ver) != .lt) return true; if (self.max.order(ver) == .lt) return false; return null; } }; pub fn order(lhs: Version, rhs: Version) std.math.Order { if (lhs.major < rhs.major) return .lt; if (lhs.major > rhs.major) return .gt; if (lhs.minor < rhs.minor) return .lt; if (lhs.minor > rhs.minor) return .gt; if (lhs.patch < rhs.patch) return .lt; if (lhs.patch > rhs.patch) return .gt; if (lhs.pre != null and rhs.pre == null) return .lt; if (lhs.pre == null and rhs.pre == null) return .eq; if (lhs.pre == null and rhs.pre != null) return .gt; // Iterate over pre-release identifiers until a difference is found. var lhs_pre_it = std.mem.splitScalar(u8, lhs.pre.?, '.'); var rhs_pre_it = std.mem.splitScalar(u8, rhs.pre.?, '.'); while (true) { const next_lid = lhs_pre_it.next(); const next_rid = rhs_pre_it.next(); // A larger set of pre-release fields has a higher precedence than a smaller set. if (next_lid == null and next_rid != null) return .lt; if (next_lid == null and next_rid == null) return .eq; if (next_lid != null and next_rid == null) return .gt; const lid = next_lid.?; // Left identifier const rid = next_rid.?; // Right identifier // Attempt to parse identifiers as numbers. Overflows are checked by parse. const lnum: ?usize = std.fmt.parseUnsigned(usize, lid, 10) catch |err| switch (err) { error.InvalidCharacter => null, error.Overflow => unreachable, }; const rnum: ?usize = std.fmt.parseUnsigned(usize, rid, 10) catch |err| switch (err) { error.InvalidCharacter => null, error.Overflow => unreachable, }; // Numeric identifiers always have lower precedence than non-numeric identifiers. if (lnum != null and rnum == null) return .lt; if (lnum == null and rnum != null) return .gt; // Identifiers consisting of only digits are compared numerically. // Identifiers with letters or hyphens are compared lexically in ASCII sort order. if (lnum != null and rnum != null) { if (lnum.? < rnum.?) return .lt; if (lnum.? > rnum.?) return .gt; } else { const ord = std.mem.order(u8, lid, rid); if (ord != .eq) return ord; } } } pub fn parse(text: []const u8) !Version { // Parse the required major, minor, and patch numbers. const extra_index = std.mem.indexOfAny(u8, text, "-+"); const required = text[0..(extra_index orelse text.len)]; var it = std.mem.splitScalar(u8, required, '.'); var ver = Version{ .major = try parseNum(it.first()), .minor = try parseNum(it.next() orelse return error.InvalidVersion), .patch = try parseNum(it.next() orelse return error.InvalidVersion), }; if (it.next() != null) return error.InvalidVersion; if (extra_index == null) return ver; // Slice optional pre-release or build metadata components. const extra: []const u8 = text[extra_index.?..text.len]; if (extra[0] == '-') { const build_index = std.mem.indexOfScalar(u8, extra, '+'); ver.pre = extra[1..(build_index orelse extra.len)]; if (build_index) |idx| ver.build = extra[(idx + 1)..]; } else { ver.build = extra[1..]; } // Check validity of optional pre-release identifiers. // See: https://semver.org/#spec-item-9 if (ver.pre) |pre| { it = std.mem.splitScalar(u8, pre, '.'); while (it.next()) |id| { // Identifiers MUST NOT be empty. if (id.len == 0) return error.InvalidVersion; // Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-]. for (id) |c| if (!std.ascii.isAlphanumeric(c) and c != '-') return error.InvalidVersion; // Numeric identifiers MUST NOT include leading zeroes. const is_num = for (id) |c| { if (!std.ascii.isDigit(c)) break false; } else true; if (is_num) _ = try parseNum(id); } } // Check validity of optional build metadata identifiers. // See: https://semver.org/#spec-item-10 if (ver.build) |build| { it = std.mem.splitScalar(u8, build, '.'); while (it.next()) |id| { // Identifiers MUST NOT be empty. if (id.len == 0) return error.InvalidVersion; // Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-]. for (id) |c| if (!std.ascii.isAlphanumeric(c) and c != '-') return error.InvalidVersion; } } return ver; } fn parseNum(text: []const u8) !usize { // Leading zeroes are not allowed. if (text.len > 1 and text[0] == '0') return error.InvalidVersion; return std.fmt.parseUnsigned(usize, text, 10) catch |err| switch (err) { error.InvalidCharacter => return error.InvalidVersion, else => |e| return e, }; } pub fn format( self: Version, comptime fmt: []const u8, options: std.fmt.FormatOptions, out_stream: anytype, ) !void { _ = options; if (fmt.len != 0) std.fmt.invalidFmtError(fmt, self); try std.fmt.format(out_stream, "{d}.{d}.{d}", .{ self.major, self.minor, self.patch }); if (self.pre) |pre| try std.fmt.format(out_stream, "-{s}", .{pre}); if (self.build) |build| try std.fmt.format(out_stream, "+{s}", .{build}); } const expect = std.testing.expect; const expectError = std.testing.expectError; test "SemanticVersion format" { // Many of these test strings are from https://github.com/semver/semver.org/issues/59#issuecomment-390854010. // Valid version strings should be accepted. for ([_][]const u8{ "0.0.4", "1.2.3", "10.20.30", "1.1.2-prerelease+meta", "1.1.2+meta", "1.1.2+meta-valid", "1.0.0-alpha", "1.0.0-beta", "1.0.0-alpha.beta", "1.0.0-alpha.beta.1", "1.0.0-alpha.1", "1.0.0-alpha0.valid", "1.0.0-alpha.0valid", "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", "1.0.0-rc.1+build.1", "2.0.0-rc.1+build.123", "1.2.3-beta", "10.2.3-DEV-SNAPSHOT", "1.2.3-SNAPSHOT-123", "1.0.0", "2.0.0", "1.1.7", "2.0.0+build.1848", "2.0.1-alpha.1227", "1.0.0-alpha+beta", "1.2.3----RC-SNAPSHOT.12.9.1--.12+788", "1.2.3----R-S.12.9.1--.12+meta", "1.2.3----RC-SNAPSHOT.12.9.1--.12", "1.0.0+0.build.1-rc.10000aaa-kk-0.1", "5.4.0-1018-raspi", "5.7.123", }) |valid| try std.testing.expectFmt(valid, "{}", .{try parse(valid)}); // Invalid version strings should be rejected. for ([_][]const u8{ "", "1", "1.2", "1.2.3-0123", "1.2.3-0123.0123", "1.1.2+.123", "+invalid", "-invalid", "-invalid+invalid", "-invalid.01", "alpha", "alpha.beta", "alpha.beta.1", "alpha.1", "alpha+beta", "alpha_beta", "alpha.", "alpha..", "beta\\", "1.0.0-alpha_beta", "-alpha.", "1.0.0-alpha..", "1.0.0-alpha..1", "1.0.0-alpha...1", "1.0.0-alpha....1", "1.0.0-alpha.....1", "1.0.0-alpha......1", "1.0.0-alpha.......1", "01.1.1", "1.01.1", "1.1.01", "1.2", "1.2.3.DEV", "1.2-SNAPSHOT", "1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788", "1.2-RC-SNAPSHOT", "-1.0.3-gamma+b7718", "+justmeta", "9.8.7+meta+meta", "9.8.7-whatever+meta+meta", "2.6.32.11-svn21605", "2.11.2(0.329/5/3)", "2.13-DEVELOPMENT", "2.3-35", "1a.4", "3.b1.0", "1.4beta", "2.7.pre", "0..3", "8.008.", "01...", "55", "foobar", "", "-1", "+4", ".", "....3", }) |invalid| try expectError(error.InvalidVersion, parse(invalid)); // Valid version string that may overflow. const big_valid = "99999999999999999999999.999999999999999999.99999999999999999"; if (parse(big_valid)) |ver| { try std.testing.expectFmt(big_valid, "{}", .{ver}); } else |err| try expect(err == error.Overflow); // Invalid version string that may overflow. const big_invalid = "99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12"; if (parse(big_invalid)) |ver| std.debug.panic("expected error, found {}", .{ver}) else |_| {} } test "SemanticVersion precedence" { // SemVer 2 spec 11.2 example: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1. try expect(order(try parse("1.0.0"), try parse("2.0.0")) == .lt); try expect(order(try parse("2.0.0"), try parse("2.1.0")) == .lt); try expect(order(try parse("2.1.0"), try parse("2.1.1")) == .lt); // SemVer 2 spec 11.3 example: 1.0.0-alpha < 1.0.0. try expect(order(try parse("1.0.0-alpha"), try parse("1.0.0")) == .lt); // SemVer 2 spec 11.4 example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < // 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0. try expect(order(try parse("1.0.0-alpha"), try parse("1.0.0-alpha.1")) == .lt); try expect(order(try parse("1.0.0-alpha.1"), try parse("1.0.0-alpha.beta")) == .lt); try expect(order(try parse("1.0.0-alpha.beta"), try parse("1.0.0-beta")) == .lt); try expect(order(try parse("1.0.0-beta"), try parse("1.0.0-beta.2")) == .lt); try expect(order(try parse("1.0.0-beta.2"), try parse("1.0.0-beta.11")) == .lt); try expect(order(try parse("1.0.0-beta.11"), try parse("1.0.0-rc.1")) == .lt); try expect(order(try parse("1.0.0-rc.1"), try parse("1.0.0")) == .lt); } test "zig_version" { // An approximate Zig build that predates this test. const older_version = .{ .major = 0, .minor = 8, .patch = 0, .pre = "dev.874" }; // Simulated compatibility check using Zig version. const compatible = comptime @import("builtin").zig_version.order(older_version) == .gt; if (!compatible) @compileError("zig_version test failed"); }