Skip to content

Instantly share code, notes, and snippets.

@karl-
Created May 2, 2024 17:32
Show Gist options
  • Save karl-/c2938f6ac2723b0ce92a314bc952ff0e to your computer and use it in GitHub Desktop.
Save karl-/c2938f6ac2723b0ce92a314bc952ff0e to your computer and use it in GitHub Desktop.
A simple type representing a https://semver.org version in C#. Unit tests are authored for Unity with NUnit.
using System;
using System.Text.RegularExpressions;
/// <summary>
/// Semantic Version type (see https://semver.org/).
/// </summary>
public class SemVer : IComparable<SemVer>, IEquatable<SemVer>
{
public readonly int Major, Minor, Patch;
public readonly string PreRelease, Build;
private static Regex SemVerPattern = new ("^([0-9]+)\\.([0-9]+)\\.([0-9]+)(-([0-9A-Za-z-\\.]+))?(\\+([0-9A-Za-z\\.-]+))?");
public bool IsPreview => !string.IsNullOrEmpty(PreRelease);
public static readonly SemVer Invalid = new SemVer(-1, -1, -1);
public SemVer(int major, int minor, int patch, string pre = "", string build = "")
{
Major = major;
Minor = minor;
Patch = patch;
PreRelease = pre;
Build = build;
}
private static int CompareIdentifier(string a, string b)
{
var a_numeric = int.TryParse(a, out var ai);
var b_numeric = int.TryParse(b, out var bi);
// Numeric identifiers always have lower precedence than non-numeric identifiers.
if (a_numeric != b_numeric)
return a_numeric ? -1 : 1;
if (ai.CompareTo(bi) != 0)
return ai.CompareTo(bi);
for(int i = 0, c = Math.Min(a.Length, b.Length); i < c; ++i)
if (a[i].CompareTo(b[i]) != 0)
return Math.Sign(a[i].CompareTo(b[i]));
return a.Length.CompareTo(b.Length);
}
public int CompareTo(SemVer other)
{
var majorComparison = Major.CompareTo(other.Major);
if (majorComparison != 0) return majorComparison;
var minorComparison = Minor.CompareTo(other.Minor);
if (minorComparison != 0) return minorComparison;
var patchComparison = Patch.CompareTo(other.Patch);
if (patchComparison != 0) return patchComparison;
if (IsPreview != other.IsPreview)
return IsPreview ? -1 : 1;
var a = string.IsNullOrEmpty(PreRelease) ? "" : PreRelease;
var b = string.IsNullOrEmpty(other.PreRelease) ? "" : other.PreRelease;
var left = a.Split('.', StringSplitOptions.RemoveEmptyEntries);
var right = b.Split('.', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0, c = Math.Min(left.Length, right.Length); i < c; ++i)
{
var identifierComparison = CompareIdentifier(left[i], right[i]);
if (identifierComparison != 0)
return identifierComparison;
}
return left.Length.CompareTo(right.Length);
}
public static SemVer Parse(string version)
{
if (string.IsNullOrEmpty(version))
throw new ArgumentNullException(nameof(version));
var match = SemVerPattern.Match(version);
if (!match.Success)
throw new FormatException("'{}' is not valid semantic version format.");
var major = int.Parse(match.Groups[1].Value);
var minor = int.Parse(match.Groups[2].Value);
var patch = int.Parse(match.Groups[3].Value);
var pre = match.Groups.Count > 5 ? match.Groups[5].Value : "";
var build = match.Groups.Count > 7 ? match.Groups[7].Value : "";
return new SemVer(major, minor, patch, pre, build);
}
public static bool TryParse(string version, out SemVer semver)
{
try
{
semver = Parse(version);
return true;
}
catch { /* ignored */ }
semver = Invalid;
return false;
}
public static bool operator <(SemVer a, SemVer b) => a.CompareTo(b) < 0;
public static bool operator >(SemVer a, SemVer b) => a.CompareTo(b) > 0;
public static bool operator ==(SemVer a, SemVer b) => a?.CompareTo(b) == 0;
public static bool operator !=(SemVer a, SemVer b) => !(a == b);
public bool Equals(SemVer other) => this == other;
public override bool Equals(object obj) => obj is SemVer other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Major, Minor, Patch);
public override string ToString() => string.Format("{0}.{1}.{2}{3}{4}",
Major, Minor, Patch,
string.IsNullOrEmpty(PreRelease) ? "" : $"-{PreRelease}",
string.IsNullOrEmpty(Build) ? "" : $"+{Build}"
);
}
using NUnit.Framework;
public class SemVerTests
{
private static readonly TestCaseData[] ParsingTestCases = new[]
{
new TestCaseData("1.0.3") { ExpectedResult = new SemVer(1, 0, 3) },
new TestCaseData("1112.1.2") { ExpectedResult = new SemVer(1112, 1, 2) },
new TestCaseData("4.3.2-") { ExpectedResult = new SemVer(4,3,2) },
new TestCaseData("-1.1.3") { ExpectedResult = SemVer.Invalid },
new TestCaseData("1-1-1") { ExpectedResult = SemVer.Invalid },
new TestCaseData(".1.1.1") { ExpectedResult = SemVer.Invalid },
new TestCaseData("1.-1.1") { ExpectedResult = SemVer.Invalid },
};
private static readonly TestCaseData[] MetaDataTestCases = new[]
{
new TestCaseData("1.0.0-alpha") { ExpectedResult = ("alpha", "") } ,
new TestCaseData("1.0.0-alpha.1") { ExpectedResult = ("alpha.1", "") } ,
new TestCaseData("1.0.0-0.3.7") { ExpectedResult = ("0.3.7", "") } ,
new TestCaseData("1.0.0-x.7.z.92") { ExpectedResult = ("x.7.z.92", "") } ,
new TestCaseData("1.0.0-x-y-z.--") { ExpectedResult = ("x-y-z.--", "") } ,
new TestCaseData("1.0.0-alpha+001") { ExpectedResult = ("alpha", "001") },
new TestCaseData("1.0.0+20130313144700") {ExpectedResult = ("", "20130313144700")},
new TestCaseData("1.0.0-beta+exp.sha.5114f85") { ExpectedResult = ("beta", "exp.sha.5114f85")},
new TestCaseData("1.0.0+21AF26D3----117B344092BD") { ExpectedResult = ("", "21AF26D3----117B344092BD") },
};
private static readonly TestCaseData[] ComparisonTestCases = new[]
{
new TestCaseData("1.2.3", "1.2.3") { ExpectedResult = 0 },
new TestCaseData("1.2.3", "1.2.4") { ExpectedResult = -1 },
new TestCaseData("1.2.3", "1.3.0") { ExpectedResult = -1 },
new TestCaseData("1.2.3", "2.0.0") { ExpectedResult = -1 },
new TestCaseData("1.2.3", "1.2.0") { ExpectedResult = 1 },
new TestCaseData("1.2.3", "1.1.4") { ExpectedResult = 1 },
new TestCaseData("2.2.3", "1.4.4") { ExpectedResult = 1 },
new TestCaseData("1.0.0-alpha", "1.0.0-alpha.1") { ExpectedResult = -1 },
new TestCaseData("1.0.0-alpha.1", "1.0.0-alpha.beta") { ExpectedResult = -1 },
new TestCaseData("1.0.0-alpha.beta", "1.0.0-beta") { ExpectedResult = -1 },
new TestCaseData("1.0.0-beta", "1.0.0-beta.2") { ExpectedResult = -1 },
new TestCaseData("1.0.0-beta.2", "1.0.0-beta.11") { ExpectedResult = -1 },
new TestCaseData("1.0.0-beta.11", "1.0.0-rc.1") { ExpectedResult = -1 },
new TestCaseData("1.0.0-rc.1", "1.0.0") { ExpectedResult = -1 },
};
[Test, TestCaseSource(nameof(ParsingTestCases))]
public SemVer StringParsing_ProducesExpectedResult( string input)
{
SemVer.TryParse(input, out var res);
return res;
}
[Test, TestCaseSource(nameof(MetaDataTestCases))]
public (string, string) StringParsing_PreservesMetaData(string input)
{
SemVer.TryParse(input, out var res);
return (res.PreRelease, res.Build);
}
[Test, TestCaseSource(nameof(ComparisonTestCases))]
public int CompareTo(string a, string b) => SemVer.Parse(a).CompareTo(SemVer.Parse(b));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment