Enhanced stout's Version to support prerelease and build labels. Previously, Stout's `Version` abstraction only supported a subset of Semver: version numbers with three numeric components (an optional trailing "label" with a leading hyphen was supported but ignored).
This commit adds support for SemVer 2.0.0, which defines two additional optional fields: a "prerelease label" and a "build metadata label", e.g., "1.2.3-alpha.1+foo". Both labels consist of a series of dot-separated identifiers. Review: https://reviews.apache.org/r/58707 Project: http://git-wip-us.apache.org/repos/asf/mesos/repo Commit: http://git-wip-us.apache.org/repos/asf/mesos/commit/405891d2 Tree: http://git-wip-us.apache.org/repos/asf/mesos/tree/405891d2 Diff: http://git-wip-us.apache.org/repos/asf/mesos/diff/405891d2 Branch: refs/heads/1.2.x Commit: 405891d207482c860ff020d2cceba136c91aaf6c Parents: 15b8ee2 Author: Neil Conway <[email protected]> Authored: Fri Apr 21 09:03:10 2017 -0700 Committer: Neil Conway <[email protected]> Committed: Fri May 5 15:14:02 2017 -0700 ---------------------------------------------------------------------- 3rdparty/stout/include/stout/version.hpp | 346 ++++++++++++++++++++++---- 3rdparty/stout/tests/version_tests.cpp | 120 +++++++-- 2 files changed, 399 insertions(+), 67 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/mesos/blob/405891d2/3rdparty/stout/include/stout/version.hpp ---------------------------------------------------------------------- diff --git a/3rdparty/stout/include/stout/version.hpp b/3rdparty/stout/include/stout/version.hpp index 7717c85..5e2bd2e 100644 --- a/3rdparty/stout/include/stout/version.hpp +++ b/3rdparty/stout/include/stout/version.hpp @@ -13,73 +13,161 @@ #ifndef __STOUT_VERSION_HPP__ #define __STOUT_VERSION_HPP__ +#include <algorithm> +#include <cctype> #include <ostream> #include <string> #include <vector> +#include <stout/check.hpp> #include <stout/error.hpp> #include <stout/numify.hpp> +#include <stout/option.hpp> #include <stout/stringify.hpp> #include <stout/strings.hpp> #include <stout/try.hpp> -// This class provides convenience routines for version checks. +// This class provides convenience routines for working with version +// numbers. We support the SemVer 2.0.0 (http://semver.org) format, +// with minor extensions. // -// Ideally, the components would be called simply major, minor and -// patch. However, GNU libstdc++ already defines these as macros for -// compatibility reasons (man 3 makedev for more information) implicitly -// included in every compilation. -// -// TODO(karya): Consider adding support for more than 3 components, and -// compatibility operators. -// TODO(karya): Add support for labels and build metadata. Consider -// semantic versioning (http://semvar.org/) for specs. +// Ideally, the version components would be called simply "major", +// "minor", and "patch". However, GNU libstdc++ already defines these +// as macros for compatibility reasons (man 3 makedev for more +// information) implicitly included in every compilation. struct Version { // Expect the string in the following format: - // <major>[.<minor>[.<patch>]] - // Missing components are treated as zero. - static Try<Version> parse(const std::string& s) + // <major>[.<minor>[.<patch>]][-prerelease][+build] + // + // Missing `minor` or `patch` components are treated as zero. + // Allowing some version numbers to be omitted is an extension to + // SemVer, albeit one that several other SemVer libraries implement. + // + // TODO(neilc): Consider providing a "strict" variant that does not + // allow version numbers to be omitted. + // + // `prerelease` is a prerelease label (e.g., "beta", "rc.1"); + // `build` is a build metadata label. Both `prerelease` and `build` + // consist of one or more dot-separated identifiers. An identifier + // is a non-empty string containing ASCII alphanumeric characters or + // hyphens. + static Try<Version> parse(const std::string& input) { - const size_t maxComponents = 3; - - // Use only the part before '-', i.e. strip and discard the tags - // and labels. - // TODO(karya): Once we have support for labels and tags, we - // should not discard the remaining string. - std::vector<std::string> split = - strings::split(strings::split(s, "-")[0], "."); - - if (split.size() > maxComponents) { - return Error("Version string has " + stringify(split.size()) + - " components; maximum " + stringify(maxComponents) + + // The input string consists of the numeric components, optionally + // followed by the prerelease label (prefixed with '-') and/or the + // build label (prefixed with '+'). We parse the string from right + // to left: build label (if any), prerelease label (if any), and + // finally numeric components. + + std::vector<std::string> buildLabel; + + std::vector<std::string> buildParts = strings::split(input, "+", 2); + CHECK(buildParts.size() == 1 || buildParts.size() == 2); + + if (buildParts.size() == 2) { + const std::string& buildString = buildParts.back(); + + // NOTE: Build metadata identifiers can contain leading zeros + // (unlike numeric prerelease identifiers; see below). + Try<std::vector<std::string>> parsed = parseLabel(buildString, false); + if (parsed.isError()) { + return Error("Invalid build label: " + parsed.error()); + } + + buildLabel = parsed.get(); + } + + std::string remainder = buildParts.front(); + + // Parse the prerelease label, if any. Note that the prerelease + // label might itself contain hyphens. + std::vector<std::string> prereleaseLabel; + + std::vector<std::string> prereleaseParts = + strings::split(remainder, "-", 2); + CHECK(prereleaseParts.size() == 1 || prereleaseParts.size() == 2); + + if (prereleaseParts.size() == 2) { + const std::string& prereleaseString = prereleaseParts.back(); + + // Prerelease identifiers cannot contain leading zeros. + Try<std::vector<std::string>> parsed = parseLabel(prereleaseString, true); + if (parsed.isError()) { + return Error("Invalid prerelease label: " + parsed.error()); + } + + prereleaseLabel = parsed.get(); + } + + remainder = prereleaseParts.front(); + + constexpr size_t maxNumericComponents = 3; + std::vector<std::string> numericComponents = strings::split(remainder, "."); + + if (numericComponents.size() > maxNumericComponents) { + return Error("Version has " + stringify(numericComponents.size()) + + " components; maximum " + stringify(maxNumericComponents) + " components allowed"); } - int components[maxComponents] = {0}; + int versionNumbers[maxNumericComponents] = {0}; - for (size_t i = 0; i < split.size(); i++) { - Try<int> result = numify<int>(split[i]); + for (size_t i = 0; i < numericComponents.size(); i++) { + Try<int> result = parseNumericIdentifier(numericComponents[i]); if (result.isError()) { - return Error("Invalid version component '" + split[i] + "': " + - result.error()); + return Error("Invalid version component '" + numericComponents[i] + "'" + ": " + result.error()); + } + + if (hasLeadingZero(numericComponents[i])) { + return Error("Invalid version component '" + numericComponents[i] + "'" + ": cannot contain leading zero"); } - components[i] = result.get(); + + versionNumbers[i] = result.get(); } - return Version(components[0], components[1], components[2]); + return Version(versionNumbers[0], + versionNumbers[1], + versionNumbers[2], + prereleaseLabel, + buildLabel); } - Version(int _majorVersion, int _minorVersion, int _patchVersion) + // Construct a new Version. The `_prerelease` and `_build` arguments + // contain lists of prerelease and build identifiers, respectively. + Version(int _majorVersion, + int _minorVersion, + int _patchVersion, + const std::vector<std::string>& _prerelease = {}, + const std::vector<std::string>& _build = {}) : majorVersion(_majorVersion), minorVersion(_minorVersion), - patchVersion(_patchVersion) {} + patchVersion(_patchVersion), + prerelease(_prerelease), + build(_build) + { + // As a sanity check, ensure that the caller has provided + // valid prerelease and build identifiers. + + foreach (const std::string& identifier, prerelease) { + CHECK_NONE(validateIdentifier(identifier, true)); + } + + foreach (const std::string& identifier, build) { + CHECK_NONE(validateIdentifier(identifier, false)); + } + } bool operator==(const Version& other) const { + // NOTE: The `build` field is ignored when comparing two versions + // for equality, per SemVer spec. return majorVersion == other.majorVersion && minorVersion == other.minorVersion && - patchVersion == other.patchVersion; + patchVersion == other.patchVersion && + prerelease == other.prerelease; } bool operator!=(const Version& other) const @@ -87,28 +175,94 @@ struct Version return !(*this == other); } + // SemVer 2.0.0 defines version precedence (ordering) like so: + // + // Precedence MUST be calculated by separating the version into + // major, minor, patch and pre-release identifiers in that order + // (Build metadata does not figure into precedence). Precedence is + // determined by the first difference when comparing each of these + // identifiers from left to right as follows: Major, minor, and + // patch versions are always compared numerically. Example: 1.0.0 + // < 2.0.0 < 2.1.0 < 2.1.1. When major, minor, and patch are + // equal, a pre-release version has lower precedence than a normal + // version. Example: 1.0.0-alpha < 1.0.0. Precedence for two + // pre-release versions with the same major, minor, and patch + // version MUST be determined by comparing each dot separated + // identifier from left to right until a difference is found as + // follows: identifiers consisting of only digits are compared + // numerically and identifiers with letters or hyphens are + // compared lexically in ASCII sort order. Numeric identifiers + // always have lower precedence than non-numeric identifiers. A + // larger set of pre-release fields has a higher precedence than a + // smaller set, if all of the preceding identifiers are equal. + // Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < + // 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0. + // + // NOTE: The `build` field is ignored when comparing two versions + // for precedence, per the SemVer spec text above. bool operator<(const Version& other) const { - // Lexicographic ordering. + // Compare version numbers numerically. if (majorVersion != other.majorVersion) { return majorVersion < other.majorVersion; - } else if (minorVersion != other.minorVersion) { + } + if (minorVersion != other.minorVersion) { return minorVersion < other.minorVersion; - } else { + } + if (patchVersion != other.patchVersion) { return patchVersion < other.patchVersion; } + + // If one version has a prerelease label and the other does not, + // the prerelease version has lower precedence. + if (!prerelease.empty() && other.prerelease.empty()) { + return true; + } + if (prerelease.empty() && !other.prerelease.empty()) { + return false; + } + + // Compare two versions with prerelease labels by proceeding from + // left to right. + size_t minPrereleaseSize = std::min( + prerelease.size(), other.prerelease.size()); + + for (size_t i = 0; i < minPrereleaseSize; i++) { + // Check whether the two prerelease identifiers can be converted + // to numbers. + Try<int> identifier = parseNumericIdentifier(prerelease.at(i)); + Try<int> otherIdentifier = parseNumericIdentifier(other.prerelease.at(i)); + + if (identifier.isSome() && otherIdentifier.isSome()) { + // Both identifiers are numeric. + if (identifier.get() != otherIdentifier.get()) { + return identifier.get() < otherIdentifier.get(); + } + } else if (identifier.isSome()) { + // `identifier` is numeric but `otherIdentifier` is not, so + // `identifier` comes first. + return true; + } else if (otherIdentifier.isSome()) { + // `otherIdentifier` is numeric but `identifier` is not, so + // `otherIdentifier` comes first. + return false; + } else { + // Neither identifier is numeric, so compare via ASCII sort. + if (prerelease.at(i) != other.prerelease.at(i)) { + return prerelease.at(i) < other.prerelease.at(i); + } + } + } + + // If two versions have different numbers of prerelease labels but + // they match on the common prefix, the version with the smaller + // set of labels comes first. + return prerelease.size() < other.prerelease.size(); } bool operator>(const Version& other) const { - // Lexicographic ordering. - if (majorVersion != other.majorVersion) { - return majorVersion > other.majorVersion; - } else if (minorVersion != other.minorVersion) { - return minorVersion > other.minorVersion; - } else { - return patchVersion > other.patchVersion; - } + return other < *this; } bool operator<=(const Version& other) const @@ -128,6 +282,90 @@ struct Version const int majorVersion; const int minorVersion; const int patchVersion; + const std::vector<std::string> prerelease; + const std::vector<std::string> build; + +private: + // Check that a string contains a valid identifier. An identifier is + // a non-empty string; each character must be an ASCII alphanumeric + // or hyphen. + static Option<Error> validateIdentifier( + const std::string& identifier, + bool rejectLeadingZero) + { + if (identifier.empty()) { + return Error("Empty identifier"); + } + + auto alphaNumericOrHyphen = [](char c) -> bool { + return std::isalnum(c) || c == '-'; + }; + + auto firstInvalid = std::find_if_not( + identifier.begin(), identifier.end(), alphaNumericOrHyphen); + + if (firstInvalid != identifier.end()) { + return Error("Identifier contains illegal character: " + "'" + stringify(*firstInvalid) + "'"); + } + + // If requested, disallow identifiers that contain a leading + // zero. Note that this only applies to numeric identifiers, and + // that zero-valued identifiers are allowed. + if (rejectLeadingZero && hasLeadingZero(identifier)) { + return Error("Identifier contains leading zero"); + } + + return None(); + } + + // Parse a string containing a series of dot-separated identifiers + // into a vector of strings; each element of the vector contains a + // single identifier. If `rejectLeadingZeros` is true, we reject any + // numeric identifier in the label that contains a leading zero. + static Try<std::vector<std::string>> parseLabel( + const std::string& label, + bool rejectLeadingZeros) + { + if (label.empty()) { + return Error("Empty label"); + } + + std::vector<std::string> identifiers = strings::split(label, "."); + + foreach (const std::string& identifier, identifiers) { + Option<Error> error = validateIdentifier(identifier, rejectLeadingZeros); + if (error.isSome()) { + return error.get(); + } + } + + return identifiers; + } + + // Checks whether the input string is numeric and contains a leading + // zero. Note that "0" by itself is not considered a "leading zero". + static bool hasLeadingZero(const std::string& identifier) { + Try<int> numericIdentifier = parseNumericIdentifier(identifier); + + return numericIdentifier.isSome() && + numericIdentifier.get() != 0 && + strings::startsWith(identifier, '0'); + } + + // Attempt to parse the given string as a numeric identifier. + // According to the SemVer spec, identifiers that begin with hyphens + // are considered non-numeric. + // + // TODO(neilc): Consider adding a variant of `numify<T>` that only + // supports non-negative inputs. + static Try<int> parseNumericIdentifier(const std::string& identifier) { + if (strings::startsWith(identifier, '-')) { + return Error("Contains leading hyphen"); + } + + return numify<int>(identifier); + } }; @@ -135,9 +373,19 @@ inline std::ostream& operator<<( std::ostream& stream, const Version& version) { - return stream << version.majorVersion << "." - << version.minorVersion << "." - << version.patchVersion; + stream << version.majorVersion << "." + << version.minorVersion << "." + << version.patchVersion; + + if (!version.prerelease.empty()) { + stream << "-" << strings::join(".", version.prerelease); + } + + if (!version.build.empty()) { + stream << "+" << strings::join(".", version.build); + } + + return stream; } #endif // __STOUT_VERSION_HPP__ http://git-wip-us.apache.org/repos/asf/mesos/blob/405891d2/3rdparty/stout/tests/version_tests.cpp ---------------------------------------------------------------------- diff --git a/3rdparty/stout/tests/version_tests.cpp b/3rdparty/stout/tests/version_tests.cpp index 925f383..bce185e 100644 --- a/3rdparty/stout/tests/version_tests.cpp +++ b/3rdparty/stout/tests/version_tests.cpp @@ -30,20 +30,64 @@ using std::vector; // Verify version comparison operations. TEST(VersionTest, Comparison) { - Version version1(0, 10, 4); - Version version2(0, 20, 3); - Try<Version> version3 = Version::parse("0.20.3"); - - EXPECT_EQ(version2, version3.get()); - EXPECT_NE(version1, version2); - EXPECT_LT(version1, version2); - EXPECT_LE(version1, version2); - EXPECT_LE(version2, version3.get()); - EXPECT_GT(version2, version1); - EXPECT_GE(version2, version1); - EXPECT_GE(version3.get(), version1); - - EXPECT_EQ(stringify(version2), "0.20.3"); + const vector<string> inputs = { + "0.0.0", + "0.2.3", + "0.9.9", + "0.10.4", + "0.20.3", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.-02", + "1.0.0-alpha.-1", + "1.0.0-alpha.1-1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0-rc.1.2", + "1.0.0", + "1.0.1", + "2.0.0" + }; + + vector<Version> versions; + + foreach (const string& input, inputs) { + Try<Version> version = Version::parse(input); + ASSERT_SOME(version); + + versions.push_back(version.get()); + } + + // Check that `versions` is in ascending order. + for (size_t i = 0; i < versions.size(); i++) { + EXPECT_FALSE(versions[i] < versions[i]) + << "Expected " << versions[i] << " < " << versions[i] << " to be false"; + + for (size_t j = i + 1; j < versions.size(); j++) { + EXPECT_TRUE(versions[i] < versions[j]) + << "Expected " << versions[i] << " < " << versions[j]; + + EXPECT_FALSE(versions[j] < versions[i]) + << "Expected " << versions[i] << " < " << versions[j] << " to be false"; + } + } +} + + +// Verify that build metadata labels are ignored when determining +// equality and ordering between versions. +TEST(VersionTest, BuildMetadataComparison) +{ + Version plain = Version(1, 2, 3); + Version buildMetadata = Version(1, 2, 3, {}, {"abc"}); + + EXPECT_TRUE(plain == buildMetadata); + EXPECT_FALSE(plain != buildMetadata); + EXPECT_FALSE(plain < buildMetadata); + EXPECT_FALSE(plain > buildMetadata); } @@ -57,11 +101,30 @@ TEST(VersionTest, ParseValid) typedef pair<Version, string> ExpectedValue; const map<string, ExpectedValue> testCases = { - // Prerelease labels are currently accepted but ignored. - {"1.20.3-rc1", {Version(1, 20, 3), "1.20.3"}}, {"1.20.3", {Version(1, 20, 3), "1.20.3"}}, {"1.20", {Version(1, 20, 0), "1.20.0"}}, - {"1", {Version(1, 0, 0), "1.0.0"}} + {"1", {Version(1, 0, 0), "1.0.0"}}, + {"1.20.3-rc1", {Version(1, 20, 3, {"rc1"}), "1.20.3-rc1"}}, + {"1.20.3--", {Version(1, 20, 3, {"-"}), "1.20.3--"}}, + {"1.20.3+-.-", {Version(1, 20, 3, {}, {"-", "-"}), "1.20.3+-.-"}}, + {"1.0.0-alpha.1", {Version(1, 0, 0, {"alpha", "1"}), "1.0.0-alpha.1"}}, + {"1.0.0-alpha+001", + {Version(1, 0, 0, {"alpha"}, {"001"}), "1.0.0-alpha+001"}}, + {"1.0.0-alpha.-123", + {Version(1, 0, 0, {"alpha", "-123"}), "1.0.0-alpha.-123"}}, + {"1+20130313144700", + {Version(1, 0, 0, {}, {"20130313144700"}), "1.0.0+20130313144700"}}, + {"1.0.0-beta+exp.sha.5114f8", + {Version(1, 0, 0, {"beta"}, {"exp", "sha", "5114f8"}), + "1.0.0-beta+exp.sha.5114f8"}}, + {"1.0.0--1", {Version(1, 0, 0, {"-1"}), "1.0.0--1"}}, + {"1.0.0-----1", {Version(1, 0, 0, {"----1"}), "1.0.0-----1"}}, + {"1-2-3+4-5", + {Version(1, 0, 0, {"2-3"}, {"4-5"}), "1.0.0-2-3+4-5"}}, + {"1-2-3.4+5.6-7", + {Version(1, 0, 0, {"2-3", "4"}, {"5", "6-7"}), "1.0.0-2-3.4+5.6-7"}}, + {"1-2.-3+4.-5", + {Version(1, 0, 0, {"2", "-3"}, {"4", "-5"}), "1.0.0-2.-3+4.-5"}} }; foreachpair (const string& input, const ExpectedValue& expected, testCases) { @@ -87,8 +150,29 @@ TEST(VersionTest, ParseInvalid) "a", "1.", ".1.2", + "0.1.-2", + "0.-1.2", "0.1.2.3", - "-1.1.2" + "-1.1.2", + "01.2.3", + "1.02.3", + "1.2.03", + "1.1.2-", + "1.1.2+", + "1.1.2-+", + "1.1.2-.", + "1.1.2+.", + "1.1.2-foo..", + "1.1.2-.foo", + "1.1.2+", + "1.1.2+foo..", + "1.1.2+.foo", + "1.1.2-al^pha", + "1.1.2+exp;", + "1.1.2-alpha.001", + "-foo", + "+foo", + u8"1.0.0-b\u00e9ta" }; foreach (const string& input, inputs) {
