diff options
-rw-r--r-- | docs/release-notes.md | 7 | ||||
-rw-r--r-- | src/SMAPI.Tests/Utilities/SemanticVersionTests.cs | 208 | ||||
-rw-r--r-- | src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs | 3 | ||||
-rw-r--r-- | src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs | 126 | ||||
-rw-r--r-- | src/SMAPI.Toolkit/SemanticVersion.cs | 72 | ||||
-rw-r--r-- | src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/LogParser.cs | 9 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/VersionConstraint.cs | 28 | ||||
-rw-r--r-- | src/SMAPI/Framework/GameVersion.cs | 30 | ||||
-rw-r--r-- | src/SMAPI/SemanticVersion.cs | 34 |
10 files changed, 407 insertions, 112 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md index 8dac1d0c..35fdbb4b 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -10,15 +10,16 @@ * Fixed update-check error if a mod's Chucklefish page has no version. * Fixed SMAPI beta versions not showing update alert on next launch (thanks to danvolchek!). -For the Console Commands mod: +* For the Console Commands mod: * Added `test_input` command to view button codes in the console. -For modders: +* For modders: * Asset propagation for player sprites now affects other players' sprites, and updates recolor maps (e.g. sleeves). * Removed invalid-schedule validation which had false positives. * Fixed `helper.Data.Read/WriteGlobalData` using the `Saves` folder. The installer will move existing folders to the appdata folder. -For SMAPI/tool developers: +* For SMAPI/tool developers: + * Added internal support for four-part versions to support SMAPI on Android. * Updated links for the new r/SMAPI subreddit. * The `/mods` web API endpoint now includes version mappings from the wiki. * Dropped API support for the pre-3.0 update-check format. 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.")] + /// <summary>Assert the parsed version when constructed from a standard string.</summary> + /// <param name="input">The version string to parse.</param> [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.")] + + /// <summary>Assert that the constructor rejects invalid values when constructed from a string.</summary> + /// <param name="input">The version string to parse.</param> + [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<ArgumentNullException>(() => new SemanticVersion(input)); + else + this.AssertAndLogException<FormatException>(() => new SemanticVersion(input)); + } + + /// <summary>Assert the parsed version when constructed from a non-standard string.</summary> + /// <param name="input">The version string to parse.</param> + [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(); + } + + /// <summary>Assert that the constructor rejects a non-standard string when the non-standard flag isn't set.</summary> + /// <param name="input">The version string to parse.</param> + [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<FormatException>(() => new SemanticVersion(input)); + } + + /// <summary>Assert the parsed version when constructed from standard parts.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> [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.")] + /// <summary>Assert the parsed version when constructed from parts including non-standard fields.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="platformRelease">The non-standard platform release number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> + [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(); + } + + /// <summary>Assert that the constructor rejects invalid values when constructed from the individual numbers.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> [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<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build)); } + /// <summary>Assert the parsed version when constructed from an assembly version.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> [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<ArgumentNullException>(() => new SemanticVersion(input)); - else - this.AssertAndLogException<FormatException>(() => new SemanticVersion(input)); - } - /**** ** CompareTo ****/ - [Test(Description = "Assert that version.CompareTo returns the expected value.")] + /// <summary>Assert that <see cref="ISemanticVersion.CompareTo"/> returns the expected value.</summary> + /// <param name="versionStrA">The left version.</param> + /// <param name="versionStrB">The right version.</param> // 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.")] + /// <summary>Assert that <see cref="ISemanticVersion.IsOlderThan(string)"/> and <see cref="ISemanticVersion.IsOlderThan(ISemanticVersion)"/> return the expected value.</summary> + /// <param name="versionStrA">The left version.</param> + /// <param name="versionStrB">The right version.</param> // 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.")] + /// <summary>Assert that <see cref="ISemanticVersion.IsNewerThan(string)"/> and <see cref="ISemanticVersion.IsNewerThan(ISemanticVersion)"/> return the expected value.</summary> + /// <param name="versionStrA">The left version.</param> + /// <param name="versionStrB">The right version.</param> // 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 ****/ + /// <summary>Assert that <see cref="ISemanticVersion.IsBetween(string, string)"/> and <see cref="ISemanticVersion.IsBetween(ISemanticVersion, ISemanticVersion)"/> return the expected value.</summary> + /// <param name="versionStr">The main version.</param> + /// <param name="lowerStr">The lower version number.</param> + /// <param name="upperStr">The upper version number.</param> [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.")] + /// <summary>Assert that the version can be round-tripped through JSON with no special configuration.</summary> + /// <param name="versionStr">The semantic version.</param> [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.")] + /// <summary>Assert that the GameVersion subclass correctly parses non-standard game versions.</summary> + /// <param name="versionStr">The raw version.</param> [TestCase("1.0")] [TestCase("1.01")] [TestCase("1.02")] @@ -307,6 +405,24 @@ namespace SMAPI.Tests.Utilities /********* ** Private methods *********/ + /// <summary>Assert that the version matches the expected parts.</summary> + /// <param name="version">The version number.</param> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> + /// <param name="nonStandard">Whether the version should be marked as non-standard.</param> + 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")}."); + } + /// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary> /// <typeparam name="T">The expected exception type.</typeparam> /// <param name="action">The action which may throw the exception.</param> 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 /// <summary>Get a string representation of the version.</summary> string ToString(); + + /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + 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 +{ + /// <summary>Reads strings into a semantic version.</summary> + internal static class SemanticVersionReader + { + /********* + ** Public methods + *********/ + /// <summary>Parse a semantic version string.</summary> + /// <param name="versionStr">The version string to parse.</param> + /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param> + /// <param name="major">The major version incremented for major API changes.</param> + /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> + /// <param name="patch">The patch version for backwards-compatible fixes.</param> + /// <param name="platformRelease">The platform-specific version (if applicable).</param> + /// <param name="prereleaseTag">An optional prerelease tag.</param> + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> + /// <returns>Returns whether the version was successfully parsed.</returns> + 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 + *********/ + /// <summary>Try to parse the next characters in a queue as a numeric part.</summary> + /// <param name="raw">The raw characters to parse.</param> + /// <param name="index">The index of the next character to read.</param> + /// <param name="part">The parsed part.</param> + 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; + } + + /// <summary>Try to parse a literal character.</summary> + /// <param name="raw">The raw characters to parse.</param> + /// <param name="index">The index of the next character to read.</param> + /// <param name="ch">The expected character.</param> + private static bool TryParseLiteral(char[] raw, ref int index, char ch) + { + if (index >= raw.Length || raw[index] != ch) + return false; + + index++; + return true; + } + + /// <summary>Try to parse a tag.</summary> + /// <param name="raw">The raw characters to parse.</param> + /// <param name="index">The index of the next character to read.</param> + /// <param name="tag">The parsed tag.</param> + 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. /// </remarks> public class SemanticVersion : ISemanticVersion { @@ -16,14 +19,7 @@ namespace StardewModdingAPI.Toolkit ** Fields *********/ /// <summary>A regex pattern matching a valid prerelease or build metadata tag.</summary> - internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; - - /// <summary>A regex pattern matching a version within a larger string.</summary> - internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?(?:\\+(?<buildmetadata>" + SemanticVersion.TagPattern + "))?"; - - /// <summary>A regular expression matching a semantic version string.</summary> - /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks> - 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 /// <summary>The patch version for backwards-compatible bug fixes.</summary> public int PatchVersion { get; } + /// <summary>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.</summary> + public int PlatformRelease { get; } + /// <summary>An optional prerelease tag.</summary> public string PrereleaseTag { get; } @@ -52,13 +51,15 @@ namespace StardewModdingAPI.Toolkit /// <param name="major">The major version incremented for major API changes.</param> /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> /// <param name="patch">The patch version for backwards-compatible fixes.</param> + /// <param name="platformRelease">The platform-specific version (if applicable).</param> /// <param name="prereleaseTag">An optional prerelease tag.</param> /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> - 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 /// <summary>Construct an instance.</summary> /// <param name="version">The semantic version string.</param> + /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param> /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> - 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); } /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> @@ -139,7 +139,7 @@ namespace StardewModdingAPI.Toolkit /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> public bool IsOlderThan(string other) { - return this.IsOlderThan(new SemanticVersion(other)); + return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true)); } /// <summary>Get whether this version is newer than the specified version.</summary> @@ -154,7 +154,7 @@ namespace StardewModdingAPI.Toolkit /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> public bool IsNewerThan(string other) { - return this.IsNewerThan(new SemanticVersion(other)); + return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true)); } /// <summary>Get whether this version is between two specified versions (inclusively).</summary> @@ -171,13 +171,15 @@ namespace StardewModdingAPI.Toolkit /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> 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)); } /// <summary>Get a string representation of the version.</summary> 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; } + /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + public bool IsNonStandard() + { + return this.PlatformRelease != 0; + } + /// <summary>Parse a version string without throwing an exception if it fails.</summary> /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> public static bool TryParse(string version, out ISemanticVersion parsed) { + return SemanticVersion.TryParseNonStandard(version, out parsed) && !parsed.IsNonStandard(); + } + + /// <summary>Parse a version string without throwing an exception if it fails, including support for non-standard extensions like <see cref="IPlatformSpecificVersion"/>.</summary> + /// <param name="version">The version string.</param> + /// <param name="parsed">The parsed representation.</param> + /// <returns>Returns whether parsing the version succeeded.</returns> + 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 /// <param name="otherMajor">The major version to compare with this instance.</param> /// <param name="otherMinor">The minor version to compare with this instance.</param> /// <param name="otherPatch">The patch version to compare with this instance.</param> + /// <param name="otherPlatformRelease">The non-standard platform release to compare with this instance.</param> /// <param name="otherTag">The prerelease tag to compare with this instance.</param> - 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); } /// <summary>Assert that the current version is valid.</summary> 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<int>(nameof(ISemanticVersion.PatchVersion)); string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag)); - return new SemanticVersion(major, minor, patch, prereleaseTag); + return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag); } /// <summary>Read a JSON string.</summary> 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 /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary> /// <remarks>The author name and description are optional.</remarks> - private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary> private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary> - private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> - private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", 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 { /// <summary>Constrains a route value to a valid semantic version.</summary> - internal class VersionConstraint : RegexRouteConstraint + internal class VersionConstraint : IRouteConstraint { /********* ** Public methods *********/ - /// <summary>Construct an instance.</summary> - public VersionConstraint() - : base(SemanticVersion.Regex) { } + /// <summary>Get whether the URL parameter contains a valid value for this constraint.</summary> + /// <param name="httpContext">An object that encapsulates information about the HTTP request.</param> + /// <param name="route">The router that this constraint belongs to.</param> + /// <param name="routeKey">The name of the parameter that is being checked.</param> + /// <param name="values">A dictionary that contains the parameters for the URL.</param> + /// <param name="routeDirection">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.</param> + /// <returns><c>true</c> if the URL parameter contains a valid value; otherwise, <c>false</c>.</returns> + 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 { - /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary> - internal class GameVersion : SemanticVersion + /// <summary>An extension of <see cref="ISemanticVersion"/> that correctly handles non-semantic versions used by Stardew Valley.</summary> + 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 /// <summary>Construct an instance.</summary> /// <param name="version">The game version string.</param> public GameVersion(string version) - : base(GameVersion.GetSemanticVersionString(version)) { } + : base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { } /// <summary>Get a string representation of the version.</summary> 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; } /// <summary>Convert a semantic version string to the equivalent game version string.</summary> /// <param name="semanticVersion">The semantic version string.</param> 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 /// <param name="majorVersion">The major version incremented for major API changes.</param> /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> - /// <param name="prerelease">An optional prerelease tag.</param> - /// <param name="build">Optional build metadata. This is ignored when determining version precedence.</param> + /// <param name="prereleaseTag">An optional prerelease tag.</param> + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null) + : this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { } + + /// <summary>Construct an instance.</summary> + /// <param name="majorVersion">The major version incremented for major API changes.</param> + /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> + /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> + /// <param name="prereleaseTag">An optional prerelease tag.</param> + /// <param name="platformRelease">The platform-specific version (if applicable).</param> + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> [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)) { } /// <summary>Construct an instance.</summary> /// <param name="version">The semantic version string.</param> /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> public SemanticVersion(string version) - : this(new Toolkit.SemanticVersion(version)) { } + : this(version, allowNonStandard: false) { } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The semantic version string.</param> + /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param> + /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> + /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> + internal SemanticVersion(string version, bool allowNonStandard) + : this(new Toolkit.SemanticVersion(version, allowNonStandard)) { } /// <summary>Construct an instance.</summary> /// <param name="version">The assembly version.</param> @@ -141,6 +159,12 @@ namespace StardewModdingAPI return this.Version.ToString(); } + /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + public bool IsNonStandard() + { + return this.Version.IsNonStandard(); + } + /// <summary>Parse a version string without throwing an exception if it fails.</summary> /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> |