From 3a7892f5f6aa540a4de9332f1f790dd9bfaf9fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Capelle?= Date: Thu, 8 Aug 2024 11:59:20 +0200 Subject: [PATCH] Add version constraint system. --- .../uibase/extensions/versionconstraints.h | 70 ++++ src/CMakeLists.txt | 2 + src/versionconstraints.cpp | 303 ++++++++++++++++++ tests/test_versioning.cpp | 110 +++++++ 4 files changed, 485 insertions(+) create mode 100644 include/uibase/extensions/versionconstraints.h create mode 100644 src/versionconstraints.cpp diff --git a/include/uibase/extensions/versionconstraints.h b/include/uibase/extensions/versionconstraints.h new file mode 100644 index 00000000..a66cbb59 --- /dev/null +++ b/include/uibase/extensions/versionconstraints.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#include + +#include "../versioning.h" + +namespace MOBase +{ +class InvalidConstraintException : public Exception +{ +public: + using Exception::Exception; +}; + +class VersionConstraintImpl; + +// class representing a version constraint, e.g. "2.3.*" or ">=2.4" +// +class QDLLEXPORT VersionConstraint +{ +public: + // wildcard placeholder for major/minor/patch/subpatch when constructing wildcard + // + static constexpr int WILDCARD = -1; + +public: + // parse a constraint from the given string + // + static VersionConstraint parse(QString const& value, Version::ParseMode mode); + +public: + // check if the given version matches this constraint + // + bool matches(Version const& version) const; + +public: + ~VersionConstraint(); + +private: + VersionConstraint(std::shared_ptr impl); + + std::shared_ptr m_Impl; +}; + +// class representing a set of version constraints, usually from dependency +// requirements e.g. "2.3.*", or ">= 2.4, <2.5" +// +class QDLLEXPORT VersionConstraints +{ +public: + // parse a set of constraints from the given string + // + static VersionConstraints parse(QString const& value, Version::ParseMode mode); + +public: + // construct a set of constraints + // + VersionConstraints(std::vector constraints); + + // check if the given version matches the set of constraints + // + bool matches(Version const& version) const; + +private: + std::vector m_Constraints; +}; + +} // namespace MOBase diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d5b23649..8e3379ec 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -40,6 +40,7 @@ set(extension_headers ../include/uibase/extensions/requirements.h ../include/uibase/extensions/theme.h ../include/uibase/extensions/translation.h + ../include/uibase/extensions/versionconstraints.h ) set(interface_headers ../include/uibase/ifiletree.h @@ -143,6 +144,7 @@ mo2_target_sources(uibase extension.cpp theme.cpp requirements.cpp + versionconstraints.cpp ) mo2_target_sources(uibase diff --git a/src/versionconstraints.cpp b/src/versionconstraints.cpp new file mode 100644 index 00000000..c0fa971f --- /dev/null +++ b/src/versionconstraints.cpp @@ -0,0 +1,303 @@ +#include "extensions/versionconstraints.h" + +#include "formatters.h" + +using VersionCompareFunction = bool (*)(MOBase::Version const& lhs, + MOBase::Version const& rhs); + +// official semver regex +static const QRegularExpression s_ConstraintStrictRegEx{ + R"(^(?P>=|<=|<|>|!=|==|\^|~)?\s*(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?)?)?$)"}; + +// for MO2, to match stuff like 1.2.3rc1 or v1.2.3a1+XXX +static const QRegularExpression s_ConstraintMO2RegEx{ + R"(^(?P>=|<=|<|>|!=|\^|~)?\s*(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*))?(?:(?Pdev|a|alpha|b|beta|rc)(?P0|[1-9](?:[.0-9])*))?)?)?$)"}; + +// match from value to release type +static const std::unordered_map + s_StringToRelease{{"dev", MOBase::Version::Development}, + {"alpha", MOBase::Version::Alpha}, + {"a", MOBase::Version::Alpha}, + {"beta", MOBase::Version::Beta}, + {"b", MOBase::Version::Beta}, + {"rc", MOBase::Version::ReleaseCandidate}}; + +#define _COMPARE_PAIR(OP) \ + { \ + #OP, +[](MOBase::Version const& lhs, MOBase::Version const& rhs) { \ + return lhs OP rhs; \ + } \ + } + +static const std::unordered_map s_CompareToFunction{ + _COMPARE_PAIR(>), _COMPARE_PAIR(>=), _COMPARE_PAIR(<), + _COMPARE_PAIR(<=), _COMPARE_PAIR(!=), _COMPARE_PAIR(==)}; + +#undef _COMPARE_PAIR + +namespace MOBase +{ + +class VersionConstraintImpl +{ +public: + virtual bool matches(Version const& version) const = 0; + virtual ~VersionConstraintImpl() = default; +}; + +// version constraint for a range with lower bound included and upper bound excluded, +// typically used for tilde, caret and wilcard constraints +// +class RangeVersionConstraint : public VersionConstraintImpl +{ +public: + RangeVersionConstraint(Version const& min, Version const& max) + : m_Min{min}, m_Max{max} + {} + + bool matches(Version const& version) const override + { + return m_Min <= version && version < m_Max; + } + +private: + Version m_Min, m_Max; +}; + +// version constraint for inequality and equality constraint +// +class InequalityVersionConstraint : public VersionConstraintImpl +{ + +public: + InequalityVersionConstraint(Version const& target, VersionCompareFunction compare) + : m_Target{target}, m_Compare{compare} + {} + + bool matches(Version const& version) const override + { + return m_Compare(version, m_Target); + } + +private: + Version m_Target; + VersionCompareFunction m_Compare; +}; + +VersionConstraint VersionConstraint::parse(QString const& value, + Version::ParseMode mode) +{ + const auto& regex = mode == Version::ParseMode::SemVer ? s_ConstraintStrictRegEx + : s_ConstraintMO2RegEx; + + const auto match = regex.match(value); + if (!match.hasMatch()) { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + const auto constraint = match.captured("constraint"); + + const auto major_s = match.captured("major"); + const auto minor_s = match.captured("minor"); + const auto patch_s = match.captured("patch"); + const auto subpatch_s = match.captured("subpatch"); + + const auto wildcard = + major_s == "*" || minor_s == "*" || patch_s == "*" || subpatch_s == "*"; + const auto tilde = match.captured("constraint") == "~"; + const auto caret = match.captured("constraint") == "^"; + + // cannot use wildcard with a constraint + if (wildcard && !constraint.isEmpty()) { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + // cannot use pre-release with wilcard, tilde or caret constraint + if ((wildcard || tilde || caret) && match.hasCaptured("prerelease")) { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + // if a part has a wildcard, lower part should be missing or wildcard (e.g., 2.*.3 + // is invalid) + if (major_s == "*" && !minor_s.isEmpty() && minor_s != "*") { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + if (minor_s == "*" && !patch_s.isEmpty() && patch_s != "*") { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + if (patch_s == "*" && !subpatch_s.isEmpty() && subpatch_s != "*") { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + std::vector> prereleases; + if (mode == Version::ParseMode::SemVer) { + for (auto& part : match.captured("prerelease") + .split(".", Qt::SplitBehaviorFlags::SkipEmptyParts)) { + // try to extract an int + bool ok = true; + const auto intValue = part.toInt(&ok); + if (ok) { + prereleases.push_back(intValue); + continue; + } + + // check if we have a valid prerelease type + const auto it = s_StringToRelease.find(part.toLower()); + if (it == s_StringToRelease.end()) { + throw InvalidVersionException( + QString::fromStdString(std::format("invalid prerelease type: '{}'", part))); + } + + prereleases.push_back(it->second); + } + } else { + prereleases.push_back(s_StringToRelease.at(match.captured("type"))); + + // for version with decimal point, e.g., 2.4.1rc1.1, we split the components into + // pre-release components to get {rc, 1, 1} - this works fine since {rc, 1} < {rc, + // 1, 1} + // + for (const auto& preVersion : + match.captured("prerelease").split(".", Qt::SkipEmptyParts)) { + prereleases.push_back(preVersion.toInt()); + } + } + + constexpr auto min_int = std::numeric_limits::min(); + constexpr auto max_int = std::numeric_limits::max(); + + std::shared_ptr impl; + + if (wildcard || caret || tilde) { + + // you can get more information at + // https://python-poetry.org/docs/dependency-specification/ + + // note that the only case where all 4 xxxOk is false is for '*' + // + bool majorOk, minorOk, patchOk, subpatchOk; + auto major = major_s.toInt(&majorOk), minor = minor_s.toInt(&minorOk), + patch = patch_s.toInt(&patchOk), subpatch = subpatch_s.toInt(&subpatchOk); + + // the lower bound is always the actual version with missing or wildcard components + // set to 0, e.g. + // - 2.3.* -> >= 2.3.0 + // - ^1 -> >= 1.0.0 + // - ^0.3 -> >= 0.3.0 + // - ~1.2 -> >= 1.2.0 + const Version min = Version(major, minor, patch, subpatch); + + // the upper bound is a bit more complicated to compute + Version max = Version(max_int, max_int, max_int, max_int); + + if (wildcard) { + // for wildcard, we increment the last non-wildcard character by one + // + if (minorOk && patchOk) { + max = Version(major, minor, patch + 1); + } else if (minorOk) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } else if (caret) { + // TODO: clean this... + + if (!minorOk && !patchOk && !subpatchOk) { + max = Version(major + 1, 0, 0); + } else if (!patchOk && !subpatchOk) { + if (major == 0) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } else if (!subpatchOk) { + if (major == 0 && minor == 0) { + max = Version(major, minor, patch + 1); + } else if (major == 0) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } else { + if (major == 0 && minor == 0 && patch == 0 && subpatch == 0) { + max = min; // this creates an impossible range (>= 0, < 0), but is expected + } else if (major == 0 && minor == 0 && patch == 0) { + max = Version(major, minor, patch, subpatch + 1); + } else if (major == 0 && minor == 0) { + max = Version(major, minor, patch + 1, 0); + } else if (major == 0) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } + + } else if (tilde) { + if (minorOk && patchOk && subpatchOk) { + max = Version(major, minor, patch, subpatch + 1); + } else if (minorOk && patchOk) { + max = Version(major, minor, patch + 1); + } else if (minorOk) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } + + impl = std::make_shared(min, max); + + } else { + auto op = match.captured("constraint"); + if (op.isEmpty()) { + op = "=="; + } + impl = std::make_shared( + Version(major_s.toInt(), minor_s.toInt(), patch_s.toInt(), subpatch_s.toInt(), + std::move(prereleases)), + s_CompareToFunction.at(op)); + } + + return VersionConstraint(std::move(impl)); +} + +VersionConstraint::VersionConstraint(std::shared_ptr impl) + : m_Impl{std::move(impl)} +{} + +VersionConstraint::~VersionConstraint() = default; + +bool VersionConstraint::matches(Version const& version) const +{ + return m_Impl->matches(version); +} + +VersionConstraints VersionConstraints::parse(QString const& value, + Version::ParseMode mode) +{ + std::vector constraints; + for (const auto& part : value.split(",")) { + constraints.push_back(VersionConstraint::parse(part.trimmed(), mode)); + } + return VersionConstraints(std::move(constraints)); +} + +bool VersionConstraints::matches(Version const& version) const +{ + return std::all_of(m_Constraints.begin(), m_Constraints.end(), + [version](const auto& constraint) { + return constraint.matches(version); + }); +} + +VersionConstraints::VersionConstraints(std::vector checkers) + : m_Constraints{std::move(checkers)} +{} + +} // namespace MOBase diff --git a/tests/test_versioning.cpp b/tests/test_versioning.cpp index 08a346fd..cce43813 100644 --- a/tests/test_versioning.cpp +++ b/tests/test_versioning.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -88,3 +89,112 @@ TEST(VersioningTest, VersionCompare) v(2, 4, 1, 0, {ReleaseCandidate, 1, 1})); ASSERT_TRUE(v(1, 0, 0) < v(2, 0, 0, Alpha)); } + +TEST(VersioningTest, VersionConstraintTest) +{ + // shortcut + using v = Version; + + constexpr auto MAX = std::numeric_limits::max(); + + auto check = [](const QString& constraint, Version const& v, + Version::ParseMode mode = Version::ParseMode::SemVer) { + return VersionConstraint::parse(constraint, mode).matches(v); + }; + + // inequality + + ASSERT_TRUE(check("2.5.2", v(2, 5, 2))); + ASSERT_FALSE(check("2.5.3", v(2, 5, 2))); + + ASSERT_TRUE(check(">2.5", v(2, 5, 1))); + ASSERT_TRUE(check(">2.5.2", v(2, 5, 3))); + ASSERT_FALSE(check(">2.5.2", v(2, 5, 2))); + ASSERT_FALSE(check(">2.5.3", v(2, 5, 2))); + + ASSERT_TRUE(check("<2.5", v(2, 4, MAX))); + + // wilcard + + ASSERT_FALSE(check("*", v(2, 4, MAX))); + + // caret + + ASSERT_TRUE(check("^1.2.3", v(1, 2, 3))); + ASSERT_TRUE(check("^1.2.3", v(1, 2, 4))); + ASSERT_TRUE(check("^1.2.3", v(1, 3, 1))); + ASSERT_TRUE(check("^1.2.3", v(1, MAX, 5))); + ASSERT_FALSE(check("^1.2.3", v(1, 2, 2, MAX))); + ASSERT_FALSE(check("^1.2.3", v(1, 1, 0))); + ASSERT_FALSE(check("^1.2.3", v(2, 0, 0))); + + ASSERT_TRUE(check("^1.2", v(1, 2, 0))); + ASSERT_TRUE(check("^1.2", v(1, 2, 4))); + ASSERT_TRUE(check("^1.2", v(1, 3, 1))); + ASSERT_TRUE(check("^1.2", v(1, 9, 5))); + ASSERT_FALSE(check("^1.2", v(1, 1, MAX))); + ASSERT_FALSE(check("^1.2", v(1, 1, 0))); + ASSERT_FALSE(check("^1.2", v(2, 0, 0))); + + ASSERT_TRUE(check("^1", v(1, 0, 0))); + ASSERT_TRUE(check("^1", v(1, 2, 4))); + ASSERT_TRUE(check("^1", v(1, 3, 1))); + ASSERT_TRUE(check("^1", v(1, 9, 5))); + ASSERT_FALSE(check("^1", v(0, MAX, MAX))); + ASSERT_FALSE(check("^1", v(0, MAX, 0))); + ASSERT_FALSE(check("^1", v(2, 0, 0))); + + ASSERT_TRUE(check("^0.2.3", v(0, 2, 3))); + ASSERT_TRUE(check("^0.2.3", v(0, 2, MAX))); + ASSERT_FALSE(check("^0.2.3", v(0, 1, MAX))); + ASSERT_FALSE(check("^0.2.3", v(0, 3, 0))); + + ASSERT_TRUE(check("^0.0.3", v(0, 0, 3))); + ASSERT_TRUE(check("^0.0.3", v(0, 0, 3, MAX))); + ASSERT_FALSE(check("^0.0.3", v(0, 0, 2, MAX))); + ASSERT_FALSE(check("^0.0.3", v(0, 0, 4))); + + ASSERT_TRUE(check("^0.0", v(0, 0, 0))); + ASSERT_TRUE(check("^0.0", v(0, 0, MAX))); + ASSERT_FALSE(check("^0.0", v(0, 1, 0))); + + ASSERT_TRUE(check("^0", v(0, 0, 0))); + ASSERT_TRUE(check("^0", v(0, MAX, MAX, MAX))); + ASSERT_FALSE(check("^0", v(1, 0, 0))); + + // tilde + + ASSERT_TRUE(check("~1.2.3", v(1, 2, 3))); + ASSERT_TRUE(check("~1.2.3", v(1, 2, 3, MAX))); + ASSERT_FALSE(check("~1.2.3", v(1, 2, 2, MAX))); + ASSERT_FALSE(check("~1.2.3", v(1, 3, 0))); + + ASSERT_TRUE(check("~1.2", v(1, 2, 0))); + ASSERT_TRUE(check("~1.2", v(1, 2, MAX, MAX))); + ASSERT_FALSE(check("~1.2", v(1, 1, MAX, MAX))); + ASSERT_FALSE(check("~1.2", v(1, 3, 0))); + + ASSERT_TRUE(check("~1", v(1, 0, 0))); + ASSERT_TRUE(check("~1", v(1, MAX, MAX, MAX))); + ASSERT_FALSE(check("~1", v(0, MAX, MAX, MAX))); + ASSERT_FALSE(check("~1", v(2, 0, 0))); +} + +TEST(VersioningTest, VersionConstraintsTest) +{ + // shortcut + using v = Version; + + auto check = [](const QString& constraints, Version const& v, + Version::ParseMode mode = Version::ParseMode::SemVer) { + return VersionConstraints::parse(constraints, mode).matches(v); + }; + + ASSERT_TRUE(check("2.5.2", v(2, 5, 2))); + ASSERT_FALSE(check("2.5.3", v(2, 5, 2))); + + ASSERT_TRUE(check(">=2.5.0, <2.6.0", v(2, 5, 2))); + ASSERT_FALSE(check(">=2.5.0, <2.6.0", v(2, 6, 0))); + ASSERT_FALSE(check(">=2.5.0, <2.6.0", v(2, 5, 0, Development))); + ASSERT_FALSE(check(">=2.5.0, <2.6.0", v(2, 4, 4))); +} \ No newline at end of file