From d1935e686c6396519a1ff9b1b429cd55adcf8d11 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 23 Jan 2020 00:31:26 -0500 Subject: add full internal support for non-standard four-part versions --- src/SMAPI.Tests/Utilities/SemanticVersionTests.cs | 208 ++++++++++++++++----- .../ISemanticVersion.cs | 3 + .../Framework/SemanticVersionReader.cs | 126 +++++++++++++ src/SMAPI.Toolkit/SemanticVersion.cs | 72 ++++--- .../Converters/SemanticVersionConverter.cs | 2 +- src/SMAPI.Web/Framework/LogParsing/LogParser.cs | 9 +- src/SMAPI.Web/Framework/VersionConstraint.cs | 28 ++- src/SMAPI/Framework/GameVersion.cs | 30 +-- src/SMAPI/SemanticVersion.cs | 34 +++- 9 files changed, 403 insertions(+), 109 deletions(-) create mode 100644 src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs (limited to 'src') diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index 48afcaa2..ac4ef39b 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -17,7 +17,8 @@ namespace SMAPI.Tests.Utilities /**** ** Constructor ****/ - [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from a string.")] + /// Assert the parsed version when constructed from a standard string. + /// The version string to parse. [TestCase("1.0", ExpectedResult = "1.0.0")] [TestCase("1.0.0", ExpectedResult = "1.0.0")] [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] @@ -29,10 +30,76 @@ namespace SMAPI.Tests.Utilities [TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] public string Constructor_FromString(string input) { - return new SemanticVersion(input).ToString(); + // act + ISemanticVersion version = new SemanticVersion(input); + + // assert + return version.ToString(); } - [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")] + + /// Assert that the constructor rejects invalid values when constructed from a string. + /// The version string to parse. + [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("1")] + [TestCase("01.0")] + [TestCase("1.05")] + [TestCase("1.5.06")] // leading zeros specifically prohibited by spec + [TestCase("1.2.3.4")] + [TestCase("1.apple")] + [TestCase("1.2.apple")] + [TestCase("1.2.3.apple")] + [TestCase("1..2..3")] + [TestCase("1.2.3-")] + [TestCase("1.2.3--some-tag")] + [TestCase("1.2.3-some-tag...")] + [TestCase("1.2.3-some-tag...4")] + [TestCase("1.2.3-some-tag.4+build...4")] + [TestCase("apple")] + [TestCase("-apple")] + [TestCase("-5")] + public void Constructor_FromString_WithInvalidValues(string input) + { + if (input == null) + this.AssertAndLogException(() => new SemanticVersion(input)); + else + this.AssertAndLogException(() => new SemanticVersion(input)); + } + + /// Assert the parsed version when constructed from a non-standard string. + /// The version string to parse. + [TestCase("1.2.3", ExpectedResult = "1.2.3")] + [TestCase("1.0.0.0", ExpectedResult = "1.0.0")] + [TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")] + [TestCase("1.2.3.4-some-tag.4 ", ExpectedResult = "1.2.3.4-some-tag.4")] + public string Constructor_FromString_NonStandard(string input) + { + // act + ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true); + + // assert + return version.ToString(); + } + + /// Assert that the constructor rejects a non-standard string when the non-standard flag isn't set. + /// The version string to parse. + [TestCase("1.0.0.0")] + [TestCase("1.0.0.5")] + [TestCase("1.2.3.4-some-tag.4 ")] + public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input) + { + Assert.Throws(() => new SemanticVersion(input)); + } + + /// Assert the parsed version when constructed from standard parts. + /// The major number. + /// The minor number. + /// The patch number. + /// The prerelease tag. + /// The build metadata. [TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")] [TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")] [TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")] @@ -49,15 +116,43 @@ namespace SMAPI.Tests.Utilities ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build); // assert - Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); - Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); - Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); - Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match the given value."); - Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match the given value."); + this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false); return version.ToString(); } - [Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")] + /// Assert the parsed version when constructed from parts including non-standard fields. + /// The major number. + /// The minor number. + /// The patch number. + /// The non-standard platform release number. + /// The prerelease tag. + /// The build metadata. + [TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")] + [TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")] + [TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")] + [TestCase(1, 2, 3, 4, " ", null, ExpectedResult = "1.2.3.4")] + [TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")] + [TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")] + [TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")] + [TestCase(1, 2, 3, 4, "some-tag.4 ", null, ExpectedResult = "1.2.3.4-some-tag.4")] + [TestCase(1, 2, 3, 4, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")] + [TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")] + public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build) + { + // act + ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build); + + // assert + this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0); + return version.ToString(); + } + + /// Assert that the constructor rejects invalid values when constructed from the individual numbers. + /// The major number. + /// The minor number. + /// The patch number. + /// The prerelease tag. + /// The build metadata. [TestCase(0, 0, 0, null, null)] [TestCase(-1, 0, 0, null, null)] [TestCase(0, -1, 0, null, null)] @@ -71,6 +166,10 @@ namespace SMAPI.Tests.Utilities this.AssertAndLogException(() => new SemanticVersion(major, minor, patch, prerelease, build)); } + /// Assert the parsed version when constructed from an assembly version. + /// The major number. + /// The minor number. + /// The patch number. [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] [TestCase(1, 0, 0, ExpectedResult = "1.0.0")] [TestCase(1, 2, 3, ExpectedResult = "1.2.3")] @@ -81,45 +180,16 @@ namespace SMAPI.Tests.Utilities ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch)); // assert - Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); - Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); - Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); + this.AssertParts(version, major, minor, patch, null, null, nonStandard: false); return version.ToString(); } - [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] - [TestCase(null)] - [TestCase("")] - [TestCase(" ")] - [TestCase("1")] - [TestCase("01.0")] - [TestCase("1.05")] - [TestCase("1.5.06")] // leading zeros specifically prohibited by spec - [TestCase("1.2.3.4")] - [TestCase("1.apple")] - [TestCase("1.2.apple")] - [TestCase("1.2.3.apple")] - [TestCase("1..2..3")] - [TestCase("1.2.3-")] - [TestCase("1.2.3--some-tag")] - [TestCase("1.2.3-some-tag...")] - [TestCase("1.2.3-some-tag...4")] - [TestCase("1.2.3-some-tag.4+build...4")] - [TestCase("apple")] - [TestCase("-apple")] - [TestCase("-5")] - public void Constructor_FromString_WithInvalidValues(string input) - { - if (input == null) - this.AssertAndLogException(() => new SemanticVersion(input)); - else - this.AssertAndLogException(() => new SemanticVersion(input)); - } - /**** ** CompareTo ****/ - [Test(Description = "Assert that version.CompareTo returns the expected value.")] + /// Assert that returns the expected value. + /// The left version. + /// The right version. // equal [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)] [TestCase("1.0", "1.0", ExpectedResult = 0)] @@ -149,15 +219,20 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] public int CompareTo(string versionStrA, string versionStrB) { + // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); ISemanticVersion versionB = new SemanticVersion(versionStrB); + + // assert return versionA.CompareTo(versionB); } /**** ** IsOlderThan ****/ - [Test(Description = "Assert that version.IsOlderThan returns the expected value.")] + /// Assert that and return the expected value. + /// The left version. + /// The right version. // keep test cases in sync with CompareTo for simplicity. // equal [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] @@ -187,15 +262,21 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] public bool IsOlderThan(string versionStrA, string versionStrB) { + // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); ISemanticVersion versionB = new SemanticVersion(versionStrB); + + // assert + Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB.ToString()), "The two signatures returned different results."); return versionA.IsOlderThan(versionB); } /**** ** IsNewerThan ****/ - [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + /// Assert that and return the expected value. + /// The left version. + /// The right version. // keep test cases in sync with CompareTo for simplicity. // equal [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] @@ -225,14 +306,22 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] public bool IsNewerThan(string versionStrA, string versionStrB) { + // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); ISemanticVersion versionB = new SemanticVersion(versionStrB); + + // assert + Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB.ToString()), "The two signatures returned different results."); return versionA.IsNewerThan(versionB); } /**** ** IsBetween ****/ + /// Assert that and return the expected value. + /// The main version. + /// The lower version number. + /// The upper version number. [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] // is between [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] @@ -250,17 +339,24 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] public bool IsBetween(string versionStr, string lowerStr, string upperStr) { + // arrange ISemanticVersion lower = new SemanticVersion(lowerStr); ISemanticVersion upper = new SemanticVersion(upperStr); ISemanticVersion version = new SemanticVersion(versionStr); + + // assert + Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower.ToString(), upper.ToString()), "The two signatures returned different results."); return version.IsBetween(lower, upper); } /**** ** Serializable ****/ - [Test(Description = "Assert that SemanticVersion can be round-tripped through JSON with no special configuration.")] + /// Assert that the version can be round-tripped through JSON with no special configuration. + /// The semantic version. [TestCase("1.0.0")] + [TestCase("1.0.0-beta.400")] + [TestCase("1.0.0-beta.400+build")] public void Serializable(string versionStr) { // act @@ -272,10 +368,12 @@ namespace SMAPI.Tests.Utilities Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version."); } + /**** ** GameVersion ****/ - [Test(Description = "Assert that the GameVersion subclass correctly parses legacy game versions.")] + /// Assert that the GameVersion subclass correctly parses non-standard game versions. + /// The raw version. [TestCase("1.0")] [TestCase("1.01")] [TestCase("1.02")] @@ -307,6 +405,24 @@ namespace SMAPI.Tests.Utilities /********* ** Private methods *********/ + /// Assert that the version matches the expected parts. + /// The version number. + /// The major number. + /// The minor number. + /// The patch number. + /// The prerelease tag. + /// The build metadata. + /// Whether the version should be marked as non-standard. + private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string prerelease, string build, bool nonStandard) + { + Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match."); + Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match."); + Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match."); + Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match."); + Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match."); + Assert.AreEqual(nonStandard, version.IsNonStandard(), $"The version is incorrectly marked {(nonStandard ? "standard" : "non-standard")}."); + } + /// Assert that the expected exception type is thrown, and log the action output and thrown exception. /// The expected exception type. /// The action which may throw the exception. diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index b8572d50..b228b2d1 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -61,5 +61,8 @@ namespace StardewModdingAPI /// Get a string representation of the version. string ToString(); + + /// Whether the version uses non-standard extensions, like four-part game versions on some platforms. + bool IsNonStandard(); } } diff --git a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs new file mode 100644 index 00000000..489e1c4d --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs @@ -0,0 +1,126 @@ +namespace StardewModdingAPI.Toolkit.Framework +{ + /// Reads strings into a semantic version. + internal static class SemanticVersionReader + { + /********* + ** Public methods + *********/ + /// Parse a semantic version string. + /// The version string to parse. + /// Whether to recognize non-standard semver extensions. + /// 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. + /// Returns whether the version was successfully parsed. + public static bool TryParse(string versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) + { + // init + major = 0; + minor = 0; + patch = 0; + platformRelease = 0; + prereleaseTag = null; + buildMetadata = null; + + // normalize + versionStr = versionStr?.Trim(); + if (string.IsNullOrWhiteSpace(versionStr)) + return false; + char[] raw = versionStr.ToCharArray(); + + // read major/minor version + int i = 0; + if (!TryParseVersionPart(raw, ref i, out major) || !TryParseLiteral(raw, ref i, '.') || !TryParseVersionPart(raw, ref i, out minor)) + return false; + + // read optional patch version + if (TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out patch)) + return false; + + // read optional non-standard platform release version + if (allowNonStandard && TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out platformRelease)) + return false; + + // read optional prerelease tag + if (TryParseLiteral(raw, ref i, '-') && !TryParseTag(raw, ref i, out prereleaseTag)) + return false; + + // read optional build tag + if (TryParseLiteral(raw, ref i, '+') && !TryParseTag(raw, ref i, out buildMetadata)) + return false; + + // validate + return i == versionStr.Length; // valid if we're at the end + } + + + /********* + ** Private methods + *********/ + /// Try to parse the next characters in a queue as a numeric part. + /// The raw characters to parse. + /// The index of the next character to read. + /// The parsed part. + private static bool TryParseVersionPart(char[] raw, ref int index, out int part) + { + part = 0; + + // take digits + string str = ""; + for (int i = index; i < raw.Length && char.IsDigit(raw[i]); i++) + str += raw[i]; + + // validate + if (str.Length == 0) + return false; + if (str.Length > 1 && str[0] == '0') + return false; // can't have leading zeros + + // parse + part = int.Parse(str); + index += str.Length; + return true; + } + + /// Try to parse a literal character. + /// The raw characters to parse. + /// The index of the next character to read. + /// The expected character. + private static bool TryParseLiteral(char[] raw, ref int index, char ch) + { + if (index >= raw.Length || raw[index] != ch) + return false; + + index++; + return true; + } + + /// Try to parse a tag. + /// The raw characters to parse. + /// The index of the next character to read. + /// The parsed tag. + private static bool TryParseTag(char[] raw, ref int index, out string tag) + { + // read tag length + int length = 0; + for (int i = index; i < raw.Length && (char.IsLetterOrDigit(raw[i]) || raw[i] == '-' || raw[i] == '.'); i++) + length++; + + // validate + if (length == 0) + { + tag = null; + return false; + } + + // parse + tag = new string(raw, index, length); + index += length; + return true; + } + } +} diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 4955dcae..5ead6dc8 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -1,5 +1,6 @@ using System; using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Framework; namespace StardewModdingAPI.Toolkit { @@ -9,6 +10,8 @@ namespace StardewModdingAPI.Toolkit /// - 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 { @@ -16,14 +19,7 @@ namespace StardewModdingAPI.Toolkit ** Fields *********/ /// A regex pattern matching a valid prerelease or build metadata tag. - internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; - - /// A regex pattern matching a version within a larger string. - internal const string UnboundedVersionPattern = @"(?>(?0|[1-9]\d*))\.(?>(?0|[1-9]\d*))(?>(?:\.(?0|[1-9]\d*))?)(?:-(?" + SemanticVersion.TagPattern + "))?(?:\\+(?" + SemanticVersion.TagPattern + "))?"; - - /// A regular expression matching a semantic version string. - /// This pattern is derived from the BNF documentation in the semver repo, with deviations to support the Stardew Valley mod conventions (see remarks on ). - internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; /********* @@ -38,6 +34,9 @@ namespace StardewModdingAPI.Toolkit /// The patch version for backwards-compatible bug fixes. 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; } + /// An optional prerelease tag. public string PrereleaseTag { get; } @@ -52,13 +51,15 @@ namespace StardewModdingAPI.Toolkit /// 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, string prereleaseTag = null, string buildMetadata = null) + 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); @@ -82,23 +83,22 @@ namespace StardewModdingAPI.Toolkit /// 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) + public SemanticVersion(string version, bool allowNonStandard = false) { - // parse if (version == null) throw new ArgumentNullException(nameof(version), "The input version string can't be null."); - var match = SemanticVersion.Regex.Match(version.Trim()); - if (!match.Success) + 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."); - // initialize - this.MajorVersion = int.Parse(match.Groups["major"].Value); - this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; - this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; - this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalizedTag(match.Groups["prerelease"].Value) : null; - this.BuildMetadata = match.Groups["buildmetadata"].Success ? this.GetNormalizedTag(match.Groups["buildmetadata"].Value) : null; + this.MajorVersion = major; + this.MinorVersion = minor; + this.PatchVersion = patch; + this.PlatformRelease = platformRelease; + this.PrereleaseTag = prereleaseTag; + this.BuildMetadata = buildMetadata; this.AssertValid(); } @@ -110,7 +110,7 @@ namespace StardewModdingAPI.Toolkit { if (other == null) throw new ArgumentNullException(nameof(other)); - return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.PrereleaseTag); + return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag); } /// Indicates whether the current object is equal to another object of the same type. @@ -139,7 +139,7 @@ namespace StardewModdingAPI.Toolkit /// The specified version is not a valid semantic version. public bool IsOlderThan(string other) { - return this.IsOlderThan(new SemanticVersion(other)); + return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true)); } /// Get whether this version is newer than the specified version. @@ -154,7 +154,7 @@ namespace StardewModdingAPI.Toolkit /// The specified version is not a valid semantic version. public bool IsNewerThan(string other) { - return this.IsNewerThan(new SemanticVersion(other)); + return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true)); } /// Get whether this version is between two specified versions (inclusively). @@ -171,13 +171,15 @@ namespace StardewModdingAPI.Toolkit /// One of the specified versions is not a valid semantic version. public bool IsBetween(string min, string max) { - return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max)); + return this.IsBetween(new SemanticVersion(min, allowNonStandard: true), new SemanticVersion(max, allowNonStandard: true)); } /// Get a string representation of the version. 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) @@ -185,15 +187,30 @@ namespace StardewModdingAPI.Toolkit return version; } + /// Whether the version uses non-standard extensions, like four-part game versions on some platforms. + 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, out ISemanticVersion parsed) + { + return SemanticVersion.TryParseNonStandard(version, out parsed) && !parsed.IsNonStandard(); + } + + /// Parse a version string without throwing an exception if it fails, including support for non-standard extensions like . + /// The version string. + /// The parsed representation. + /// Returns whether parsing the version succeeded. + public static bool TryParseNonStandard(string version, out ISemanticVersion parsed) { try { - parsed = new SemanticVersion(version); + parsed = new SemanticVersion(version, true); return true; } catch @@ -219,8 +236,9 @@ namespace StardewModdingAPI.Toolkit /// 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, string otherTag) + private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string otherTag) { const int same = 0; const int curNewer = 1; @@ -233,6 +251,8 @@ namespace StardewModdingAPI.Toolkit 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; @@ -274,7 +294,7 @@ namespace StardewModdingAPI.Toolkit } // fallback (this should never happen) - return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase); + return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase); } /// Assert that the current version is valid. diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index ece4a72e..e1b9db1d 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -68,7 +68,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters int patch = obj.ValueIgnoreCase(nameof(ISemanticVersion.PatchVersion)); string prereleaseTag = obj.ValueIgnoreCase(nameof(ISemanticVersion.PrereleaseTag)); - return new SemanticVersion(major, minor, patch, prereleaseTag); + return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag); } /// Read a JSON string. diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 1210f708..cc91ec51 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using StardewModdingAPI.Toolkit; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.Framework.LogParsing @@ -31,22 +30,22 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// A regex pattern matching an entry in SMAPI's mod list. /// The author name and description are optional. - private readonly Regex ModListEntryPattern = new Regex(@"^ (?.+?) (?" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?[^\|]+))?(?: \| (?.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListEntryPattern = new Regex(@"^ (?.+?) (?[^\s]+)(?: by (?[^\|]+))?(?: \| (?.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// A regex pattern matching the start of SMAPI's content pack list. private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// A regex pattern matching an entry in SMAPI's content pack list. - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?.+?) (?" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?[^\|]+))? \| for (?[^\|]+)(?: \| (?.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?.+?) (?[^\s]+)(?: by (?[^\|]+))? \| for (?[^\|]+)(?: \| (?.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// A regex pattern matching the start of SMAPI's mod update list. private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// A regex pattern matching an entry in SMAPI's mod update list. - private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?.+?) (?" + SemanticVersion.UnboundedVersionPattern + @"): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?.+?) (?[^\s]+): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// A regex pattern matching SMAPI's update line. - private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?" + SemanticVersion.UnboundedVersionPattern + @"): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?[^\s]+): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index 2d6ec603..72f5ef84 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -1,16 +1,34 @@ -using Microsoft.AspNetCore.Routing.Constraints; +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework { /// Constrains a route value to a valid semantic version. - internal class VersionConstraint : RegexRouteConstraint + internal class VersionConstraint : IRouteConstraint { /********* ** Public methods *********/ - /// Construct an instance. - public VersionConstraint() - : base(SemanticVersion.Regex) { } + /// Get whether the URL parameter contains a valid value for this constraint. + /// An object that encapsulates information about the HTTP request. + /// The router that this constraint belongs to. + /// The name of the parameter that is being checked. + /// A dictionary that contains the parameters for the URL. + /// An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated. + /// true if the URL parameter contains a valid value; otherwise, false. + public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + { + if (routeKey == null) + throw new ArgumentNullException(nameof(routeKey)); + if (values == null) + throw new ArgumentNullException(nameof(values)); + + return + values.TryGetValue(routeKey, out object routeValue) + && routeValue is string routeStr + && SemanticVersion.TryParseNonStandard(routeStr, out _); + } } } diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index 29cfbc39..07957624 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -3,8 +3,8 @@ using System.Collections.Generic; namespace StardewModdingAPI.Framework { - /// An implementation of that correctly handles the non-semantic versions used by older Stardew Valley releases. - internal class GameVersion : SemanticVersion + /// An extension of that correctly handles non-semantic versions used by Stardew Valley. + internal class GameVersion : Toolkit.SemanticVersion { /********* ** Private methods @@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework ["1.03"] = "1.0.3", ["1.04"] = "1.0.4", ["1.05"] = "1.0.5", - ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. - ["1.051b"] = "1.0.6-prerelease2", + ["1.051"] = "1.0.5.1", + ["1.051b"] = "1.0.5.2", ["1.06"] = "1.0.6", ["1.07"] = "1.0.7", - ["1.07a"] = "1.0.8-prerelease1", + ["1.07a"] = "1.0.7.1", ["1.08"] = "1.0.8", ["1.1"] = "1.1.0", ["1.2"] = "1.2.0", @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework /// Construct an instance. /// The game version string. public GameVersion(string version) - : base(GameVersion.GetSemanticVersionString(version)) { } + : base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { } /// Get a string representation of the version. public override string ToString() @@ -53,33 +53,21 @@ namespace StardewModdingAPI.Framework private static string GetSemanticVersionString(string gameVersion) { // mapped version - if (GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)) - return semanticVersion; - - // special case: four-part versions - string[] parts = gameVersion.Split('.'); - if (parts.Length == 4) - return $"{parts[0]}.{parts[1]}.{parts[2]}+{parts[3]}"; - - return gameVersion; + return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) + ? semanticVersion + : gameVersion; } /// Convert a semantic version string to the equivalent game version string. /// The semantic version string. private static string GetGameVersionString(string semanticVersion) { - // mapped versions foreach (var mapping in GameVersion.VersionMap) { if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase)) return mapping.Key; } - // special case: four-part versions - string[] parts = semanticVersion.Split('.', '+'); - if (parts.Length == 4) - return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}"; - return semanticVersion; } } diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 2a33ecef..4a175efe 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -39,18 +39,36 @@ namespace StardewModdingAPI /// The major version incremented for major API changes. /// The minor version incremented for backwards-compatible changes. /// The patch version for backwards-compatible bug fixes. - /// An optional prerelease tag. - /// Optional build metadata. This is ignored when determining version precedence. + /// An optional prerelease tag. + /// Optional build metadata. This is ignored when determining version precedence. + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null) + : this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { } + + /// 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 bug fixes. + /// An optional prerelease tag. + /// The platform-specific version (if applicable). + /// Optional build metadata. This is ignored when determining version precedence. [JsonConstructor] - public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prerelease = null, string build = null) - : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, prerelease, build)) { } + internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string prereleaseTag = null, string buildMetadata = null) + : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, platformRelease, prereleaseTag, buildMetadata)) { } /// Construct an instance. /// The semantic version string. /// The is null. /// The is not a valid semantic version. public SemanticVersion(string version) - : this(new Toolkit.SemanticVersion(version)) { } + : this(version, allowNonStandard: false) { } + + /// Construct an instance. + /// The semantic version string. + /// Whether to recognize non-standard semver extensions. + /// The is null. + /// The is not a valid semantic version. + internal SemanticVersion(string version, bool allowNonStandard) + : this(new Toolkit.SemanticVersion(version, allowNonStandard)) { } /// Construct an instance. /// The assembly version. @@ -141,6 +159,12 @@ namespace StardewModdingAPI return this.Version.ToString(); } + /// Whether the version uses non-standard extensions, like four-part game versions on some platforms. + public bool IsNonStandard() + { + return this.Version.IsNonStandard(); + } + /// Parse a version string without throwing an exception if it fails. /// The version string. /// The parsed representation. -- cgit