using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using StardewModdingAPI.Toolkit.Framework;
namespace StardewModdingAPI.Toolkit
{
/// A semantic version with an optional release tag.
///
/// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations:
/// - short-form "x.y" versions are supported (equivalent to "x.y.0");
/// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild");
/// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial").
///
/// This optionally also supports four-part versions, a non-standard extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.
///
public class SemanticVersion : ISemanticVersion
{
/*********
** Fields
*********/
/// A regex pattern matching a valid prerelease or build metadata tag.
private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+";
/*********
** Accessors
*********/
///
public int MajorVersion { get; }
///
public int MinorVersion { get; }
///
public int PatchVersion { get; }
/// The platform release. This is a non-standard semver extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.
public int PlatformRelease { get; }
///
public string? PrereleaseTag { get; }
///
public string? BuildMetadata { get; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The major version incremented for major API changes.
/// The minor version incremented for backwards-compatible changes.
/// The patch version for backwards-compatible fixes.
/// The platform-specific version (if applicable).
/// An optional prerelease tag.
/// Optional build metadata. This is ignored when determining version precedence.
public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string? prereleaseTag = null, string? buildMetadata = null)
{
this.MajorVersion = major;
this.MinorVersion = minor;
this.PatchVersion = patch;
this.PlatformRelease = platformRelease;
this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag);
this.BuildMetadata = this.GetNormalizedTag(buildMetadata);
this.AssertValid();
}
/// Construct an instance.
/// The assembly version.
/// The is null.
public SemanticVersion(Version version)
{
if (version == null)
throw new ArgumentNullException(nameof(version), "The input version can't be null.");
this.MajorVersion = version.Major;
this.MinorVersion = version.Minor;
this.PatchVersion = version.Build;
this.AssertValid();
}
/// Construct an instance.
/// The semantic version string.
/// Whether to recognize non-standard semver extensions.
/// The is null.
/// The is not a valid semantic version.
public SemanticVersion(string version, bool allowNonStandard = false)
{
if (version == null)
throw new ArgumentNullException(nameof(version), "The input version string can't be null.");
if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) || (!allowNonStandard && platformRelease != 0))
throw new FormatException($"The input '{version}' isn't a valid semantic version.");
this.MajorVersion = major;
this.MinorVersion = minor;
this.PatchVersion = patch;
this.PlatformRelease = platformRelease;
this.PrereleaseTag = prereleaseTag;
this.BuildMetadata = buildMetadata;
this.AssertValid();
}
///
public int CompareTo(ISemanticVersion? other)
{
return other == null
? 1
: this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag);
}
///
public bool Equals(ISemanticVersion? other)
{
return other != null && this.CompareTo(other) == 0;
}
///
#if NET5_0_OR_GREATER
[MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))]
#endif
public bool IsPrerelease()
{
return !string.IsNullOrWhiteSpace(this.PrereleaseTag);
}
///
public bool IsOlderThan(ISemanticVersion? other)
{
return this.CompareTo(other) < 0;
}
///
public bool IsOlderThan(string? other)
{
ISemanticVersion? otherVersion = other != null
? new SemanticVersion(other, allowNonStandard: true)
: null;
return this.IsOlderThan(otherVersion);
}
///
public bool IsNewerThan(ISemanticVersion? other)
{
return this.CompareTo(other) > 0;
}
///
public bool IsNewerThan(string? other)
{
ISemanticVersion? otherVersion = other != null
? new SemanticVersion(other, allowNonStandard: true)
: null;
return this.IsNewerThan(otherVersion);
}
///
public bool IsBetween(ISemanticVersion? min, ISemanticVersion? max)
{
return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0;
}
///
public bool IsBetween(string? min, string? max)
{
ISemanticVersion? minVersion = min != null
? new SemanticVersion(min, allowNonStandard: true)
: null;
ISemanticVersion? maxVersion = max != null
? new SemanticVersion(max, allowNonStandard: true)
: null;
return this.IsBetween(minVersion, maxVersion);
}
///
public override string ToString()
{
string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}";
if (this.PlatformRelease != 0)
version += $".{this.PlatformRelease}";
if (this.PrereleaseTag != null)
version += $"-{this.PrereleaseTag}";
if (this.BuildMetadata != null)
version += $"+{this.BuildMetadata}";
return version;
}
///
public bool IsNonStandard()
{
return this.PlatformRelease != 0;
}
/// Parse a version string without throwing an exception if it fails.
/// The version string.
/// The parsed representation.
/// Returns whether parsing the version succeeded.
public static bool TryParse(string? version,
#if NET5_0_OR_GREATER
[NotNullWhen(true)]
#endif
out ISemanticVersion? parsed
)
{
return SemanticVersion.TryParse(version, allowNonStandard: false, out parsed);
}
/// Parse a version string without throwing an exception if it fails.
/// The version string.
/// Whether to allow non-standard extensions to semantic versioning.
/// The parsed representation.
/// Returns whether parsing the version succeeded.
public static bool TryParse(string? version, bool allowNonStandard,
#if NET5_0_OR_GREATER
[NotNullWhen(true)]
#endif
out ISemanticVersion? parsed
)
{
if (version == null)
{
parsed = null;
return false;
}
try
{
parsed = new SemanticVersion(version, allowNonStandard);
return true;
}
catch
{
parsed = null;
return false;
}
}
/*********
** Private methods
*********/
/// Get a normalized prerelease or build tag.
/// The tag to normalize.
private string? GetNormalizedTag(string? tag)
{
tag = tag?.Trim();
return !string.IsNullOrWhiteSpace(tag) ? tag : null;
}
/// Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version.
/// The major version to compare with this instance.
/// The minor version to compare with this instance.
/// The patch version to compare with this instance.
/// The non-standard platform release to compare with this instance.
/// The prerelease tag to compare with this instance.
private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string? otherTag)
{
const int same = 0;
const int curNewer = 1;
const int curOlder = -1;
int CompareToRaw()
{
// compare stable versions
if (this.MajorVersion != otherMajor)
return this.MajorVersion.CompareTo(otherMajor);
if (this.MinorVersion != otherMinor)
return this.MinorVersion.CompareTo(otherMinor);
if (this.PatchVersion != otherPatch)
return this.PatchVersion.CompareTo(otherPatch);
if (this.PlatformRelease != otherPlatformRelease)
return this.PlatformRelease.CompareTo(otherPlatformRelease);
if (this.PrereleaseTag == otherTag)
return same;
// stable supersedes prerelease
bool curIsStable = string.IsNullOrWhiteSpace(this.PrereleaseTag);
bool otherIsStable = string.IsNullOrWhiteSpace(otherTag);
if (curIsStable)
return curNewer;
if (otherIsStable)
return curOlder;
// compare two prerelease tag values
string[] curParts = this.PrereleaseTag?.Split('.', '-') ?? Array.Empty();
string[] otherParts = otherTag?.Split('.', '-') ?? Array.Empty();
int length = Math.Max(curParts.Length, otherParts.Length);
for (int i = 0; i < length; i++)
{
// longer prerelease tag supersedes if otherwise equal
if (curParts.Length <= i)
return curOlder;
if (otherParts.Length <= i)
return curNewer;
// skip if same value, unless we've reached the end
if (curParts[i] == otherParts[i])
{
if (i == length - 1)
return same;
continue;
}
// unofficial is always lower-precedence
if (otherParts[i].Equals("unofficial", StringComparison.OrdinalIgnoreCase))
return curNewer;
if (curParts[i].Equals("unofficial", StringComparison.OrdinalIgnoreCase))
return curOlder;
// compare numerically if possible
{
if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum))
return curNum.CompareTo(otherNum);
}
// else compare lexically
return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase);
}
// fallback (this should never happen)
return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.OrdinalIgnoreCase);
}
int result = CompareToRaw();
if (result < 0)
return curOlder;
if (result > 0)
return curNewer;
return same;
}
/// Assert that the current version is valid.
private void AssertValid()
{
if (this.MajorVersion < 0 || this.MinorVersion < 0 || this.PatchVersion < 0)
throw new FormatException($"{this} isn't a valid semantic version. The major, minor, and patch numbers can't be negative.");
if (this.MajorVersion == 0 && this.MinorVersion == 0 && this.PatchVersion == 0)
throw new FormatException($"{this} isn't a valid semantic version. At least one of the major, minor, and patch numbers must be more than zero.");
if (this.PrereleaseTag != null)
{
if (this.PrereleaseTag.Trim() == "")
throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag cannot be a blank string (but may be omitted).");
if (!Regex.IsMatch(this.PrereleaseTag, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase))
throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag is invalid.");
}
if (this.BuildMetadata != null)
{
if (this.BuildMetadata.Trim() == "")
throw new FormatException($"{this} isn't a valid semantic version. The build metadata cannot be a blank string (but may be omitted).");
if (!Regex.IsMatch(this.BuildMetadata, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase))
throw new FormatException($"{this} isn't a valid semantic version. The build metadata is invalid.");
}
}
}
}