Created
May 2, 2024 17:32
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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