From ec744d0a2212b11c77b32ca56f92bd8cefab9397 Mon Sep 17 00:00:00 2001 From: NopeCharon <115794254+NopeCharon@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:19:01 +0530 Subject: [PATCH] fix: a partial caret or tilde requirement treats unwritten segments as wildcards `^0` and `^0.0` bounded their range at 0.0.1, and `~1` and `~0` at 1.1 and 0.1. The caret branch chose the segment to bump through major(), minor() and patch(), which read a segment that was never written as 0, so `^0` fell through to bumping the patch; the tilde branch always bumped the minor. A dependency written `"^0"` against an index with 0.3.0 and 0.4.0 failed with "matches none of", and `"~1"` never selected 1.1.0 or later. The grammar is Cargo's, and Cargo reads an unwritten segment as a wildcard: `^0` is >=0.0.0, <1.0.0; `^0.0` is >=0.0.0, <0.1.0; `~1` is >=1.0.0, <2.0.0. mcpp.xpkg_version already bounds a partial caret this way. The caret bound now bumps the leftmost non-zero segment, looking no further than the patch and stopping at the last segment written; the tilde bound holds the major alone when no minor is written. Every fully written requirement (`^0.0.0`, `^0.0.3`, `^1.2.3`, `~1.2`, the four-segment date versions) keeps its bound. Unit tests cover the partial caret and tilde forms next to the existing caret and tilde cases, and docs/05-dependencies.md (both languages) lists the two forms. Closes #864 --- docs/05-dependencies.md | 2 + docs/zh/05-dependencies.md | 2 + modules/versioning/src/version_req.cppm | 27 +++++++++---- tests/unit/test_version_req.cpp | 50 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 95c0af0dc..6729a5c0b 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -115,6 +115,8 @@ refused offline. [dependencies] foo = "^1.2.3" # >= 1.2.3, < 2.0.0 (caret, default) bar = "~1.2.3" # >= 1.2.3, < 1.3.0 (tilde) +cat = "^0" # >= 0.0.0, < 1.0.0 (a segment not written is a wildcard) +dog = "~1" # >= 1.0.0, < 2.0.0 baz = "=1.2.3" # Exact match qux = ">=1.0, <2.0" # Range combination ``` diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 18d5a4e84..70a964b4b 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -105,6 +105,8 @@ commit。`git =` 指向本地目录(或 `file://` URL)时,这两件事都 [dependencies] foo = "^1.2.3" # >= 1.2.3, < 2.0.0 (caret, default) bar = "~1.2.3" # >= 1.2.3, < 1.3.0 (tilde) +cat = "^0" # >= 0.0.0, < 1.0.0 (a segment not written is a wildcard) +dog = "~1" # >= 1.0.0, < 2.0.0 baz = "=1.2.3" # Exact match qux = ">=1.0, <2.0" # Range combination ``` diff --git a/modules/versioning/src/version_req.cppm b/modules/versioning/src/version_req.cppm index fac07d94d..e887cee44 100644 --- a/modules/versioning/src/version_req.cppm +++ b/modules/versioning/src/version_req.cppm @@ -4,6 +4,8 @@ // "1.2.3" → caret-default: >=1.2.3, <2.0.0 // "^1.2.3" → explicit caret // "~1.2.3" → tilde: >=1.2.3, <1.3.0 +// "^0", "^0.0" → partial caret: <1.0.0, <0.1.0 (an unwritten segment is a wildcard) +// "~1" → partial tilde: >=1.0.0, <2.0.0 // "=1.2.3" → exact: ==1.2.3 // ">=1.2, <2.0" → comma-separated comparators (AND) // "*" → any @@ -372,8 +374,13 @@ bool matches(const Requirement& r, const Version& v) { case Op::Le: if (!(v <= c.v)) return false; break; case Op::Caret: { // ^X.Y.Z = >=X.Y.Z, <(X+1).0.0 (leftmost-nonzero rule) - // For simplicity here: bump major; if major==0 bump minor; if both 0 bump patch. - // Every branch must also zero the segments AFTER the bumped one, + // The bound bumps the leftmost non-zero segment, looking no + // further than the patch. A segment that is not written is a + // wildcard rather than a zero, so the search also stops at the + // last one written: `^0` is <1.0.0 and `^0.0` is <0.1.0, as in + // Cargo, while `^0.0.0` stays <0.0.1. Reading the absent + // segments as zeros made `^0` and `^0.0` admit 0.0.0 alone. + // The bump must also zero the segments AFTER the bumped one, // or the upper bound inherits the constraint's own tail and // wrongly excludes releases below it (^2026.7.27.3 would cut off // 2027.0.0.0..2). The bound is a release, never a pre-release: @@ -385,19 +392,23 @@ bool matches(const Requirement& r, const Version& v) { ++upper.nums[idx]; upper.nums.resize(idx + 1); }; - if (c.v.major() != 0) bump_at(0); - else if (c.v.minor() != 0) bump_at(1); - else bump_at(2); + const std::size_t written = std::clamp(c.v.components(), 1, 3); + std::size_t at = written - 1; + for (std::size_t i = 0; i + 1 < written; ++i) + if (c.v.seg(i) != 0) { at = i; break; } + bump_at(at); if (!(v >= c.v && v < upper)) return false; break; } case Op::Tilde: { // ~X.Y.Z = >=X.Y.Z, =X.0.0, <(X+1).0.0 -- with the minor not written + // there is no minor to hold, so only the major is (Cargo). Version upper = c.v; upper.prerelease.clear(); - upper.nums.resize(std::max(upper.nums.size(), 2), 0); - ++upper.nums[1]; - upper.nums.resize(2); + const std::size_t at = c.v.components() == 1 ? 0 : 1; + upper.nums.resize(at + 1); + ++upper.nums[at]; if (!(v >= c.v && v < upper)) return false; break; } diff --git a/tests/unit/test_version_req.cpp b/tests/unit/test_version_req.cpp index adeb0cec6..e7d5fba4f 100644 --- a/tests/unit/test_version_req.cpp +++ b/tests/unit/test_version_req.cpp @@ -64,6 +64,56 @@ TEST(VersionReq, MatchTilde) { EXPECT_FALSE(matches(*r, *parse_version("1.2.2"))); } +// A segment that is not written is a wildcard, not a zero (Cargo's partial +// requirements). Reading `^0` as `^0.0.0` made it admit 0.0.0 alone. +TEST(VersionReq, PartialCaretBoundsFollowTheWrittenSegments) { + auto major = parse_req("^0"); ASSERT_TRUE(major); + EXPECT_TRUE (matches(*major, *parse_version("0.0.0"))); + EXPECT_TRUE (matches(*major, *parse_version("0.0.5"))); + EXPECT_TRUE (matches(*major, *parse_version("0.9.3"))); + EXPECT_FALSE(matches(*major, *parse_version("1.0.0"))); + + auto minor = parse_req("^0.0"); ASSERT_TRUE(minor); + EXPECT_TRUE (matches(*minor, *parse_version("0.0.0"))); + EXPECT_TRUE (matches(*minor, *parse_version("0.0.7"))); + EXPECT_FALSE(matches(*minor, *parse_version("0.1.0"))); + + // Every segment written: the leftmost-nonzero rule is unchanged. + auto patch = parse_req("^0.0.0"); ASSERT_TRUE(patch); + EXPECT_TRUE (matches(*patch, *parse_version("0.0.0"))); + EXPECT_FALSE(matches(*patch, *parse_version("0.0.1"))); + + auto exact = parse_req("^0.0.3"); ASSERT_TRUE(exact); + EXPECT_TRUE (matches(*exact, *parse_version("0.0.3"))); + EXPECT_FALSE(matches(*exact, *parse_version("0.0.4"))); + + auto one = parse_req("^1"); ASSERT_TRUE(one); + EXPECT_TRUE (matches(*one, *parse_version("1.9.9"))); + EXPECT_FALSE(matches(*one, *parse_version("2.0.0"))); + + auto zeroFive = parse_req("^0.5"); ASSERT_TRUE(zeroFive); + EXPECT_TRUE (matches(*zeroFive, *parse_version("0.5.9"))); + EXPECT_FALSE(matches(*zeroFive, *parse_version("0.6.0"))); +} + +TEST(VersionReq, PartialTildeWithOnlyAMajorHoldsTheMajor) { + auto r = parse_req("~1"); ASSERT_TRUE(r); + EXPECT_TRUE (matches(*r, *parse_version("1.0.0"))); + EXPECT_TRUE (matches(*r, *parse_version("1.1.0"))); + EXPECT_TRUE (matches(*r, *parse_version("1.9.9"))); + EXPECT_FALSE(matches(*r, *parse_version("2.0.0"))); + EXPECT_FALSE(matches(*r, *parse_version("0.9.9"))); + + auto zero = parse_req("~0"); ASSERT_TRUE(zero); + EXPECT_TRUE (matches(*zero, *parse_version("0.4.0"))); + EXPECT_FALSE(matches(*zero, *parse_version("1.0.0"))); + + // With the minor written, tilde holds it, as before. + auto minor = parse_req("~1.2"); ASSERT_TRUE(minor); + EXPECT_TRUE (matches(*minor, *parse_version("1.2.9"))); + EXPECT_FALSE(matches(*minor, *parse_version("1.3.0"))); +} + TEST(VersionReq, MatchRangeAnd) { auto r = parse_req(">=1.2, <2.0"); ASSERT_TRUE(r); EXPECT_TRUE (matches(*r, *parse_version("1.2.0")));