Skip to content

Commit 12ad698

Browse files
authored
feat: added minimum method (#14)
1 parent ca0d871 commit 12ad698

File tree

3 files changed

+419
-16
lines changed

3 files changed

+419
-16
lines changed

include/version_weaver.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ static constexpr size_t MAX_VERSION_LENGTH = 256;
2525
bool validate(std::string_view version);
2626

2727
bool satisfies(std::string_view version, std::string_view range);
28-
std::optional<std::string> coerce(const std::string& version);
29-
std::string minimum(std::string_view range);
28+
std::optional<std::string> coerce(const std::string_view version);
29+
std::optional<std::string> incrementVersion(std::string_view version);
30+
std::optional<std::string> decrementVersion(std::string_view version);
31+
std::optional<std::string> minimum(std::string_view range);
3032

3133
// A normal version number MUST take the form X.Y.Z where X, Y, and Z are
3234
// non-negative integers, and MUST NOT contain leading zeroes.

src/version_weaver.cpp

Lines changed: 337 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
#include "version_weaver.h"
2+
23
#include <algorithm>
34
#include <cctype>
4-
#include <regex>
55
#include <charconv>
6+
#include <format>
7+
#include <regex>
68

79
namespace version_weaver {
810
bool validate(std::string_view version) { return parse(version).has_value(); }
911

10-
std::optional<std::string> coerce(const std::string& version) {
12+
std::optional<std::string> coerce(std::string_view version) {
1113
if (version.empty()) {
1214
return std::nullopt;
1315
}
1416

1517
// Regular expression to match major, minor, and patch components
1618
std::regex semverRegex(R"((\d+)(?:\.(\d+))?(?:\.(\d+))?)");
1719
std::smatch match;
20+
std::string version_str(version);
1821

19-
if (std::regex_search(version, match, semverRegex)) {
22+
if (std::regex_search(version_str, match, semverRegex)) {
2023
std::string major =
2124
std::to_string(std::stoll(match[1].str())); // First number
2225
std::string minor = match[2].matched
@@ -32,7 +35,337 @@ std::optional<std::string> coerce(const std::string& version) {
3235
return std::nullopt;
3336
}
3437

35-
std::string minimum(std::string_view range) { return ""; }
38+
constexpr bool is_digit(const char c) noexcept { return c >= '0' && c <= '9'; }
39+
40+
std::vector<std::string_view> split(const std::string_view &s) {
41+
std::vector<std::string_view> parts;
42+
size_t start = 0;
43+
while (start < s.size()) {
44+
size_t end = s.find_first_of(".-", start);
45+
if (end == std::string_view::npos) {
46+
parts.push_back(s.substr(start));
47+
break;
48+
} else {
49+
parts.push_back(s.substr(start, end - start));
50+
}
51+
start = end + 1;
52+
}
53+
return parts;
54+
}
55+
56+
bool compareSemVer(const std::string_view &a, const std::string_view &b) {
57+
auto a_parts = split(a);
58+
auto b_parts = split(b);
59+
60+
size_t min_size = std::min(a_parts.size(), b_parts.size());
61+
for (size_t i = 0; i < min_size; i++) {
62+
bool a_is_digit = !a_parts[i].empty() && std::isdigit(a_parts[i][0]);
63+
bool b_is_digit = !b_parts[i].empty() && std::isdigit(b_parts[i][0]);
64+
65+
if (a_is_digit && b_is_digit) {
66+
int num_a = std::stoi(std::string(a_parts[i]));
67+
int num_b = std::stoi(std::string(b_parts[i]));
68+
if (num_a != num_b) {
69+
return num_a < num_b;
70+
}
71+
} else {
72+
if (a_parts[i] != b_parts[i]) {
73+
return a_parts[i] < b_parts[i];
74+
}
75+
}
76+
}
77+
78+
return a_parts.size() < b_parts.size();
79+
}
80+
81+
std::optional<std::string> incrementVersion(std::string_view version) {
82+
// First, we look for the '-' character to separate the pre-release part.
83+
std::string_view numPart;
84+
std::string_view preRelease;
85+
size_t dashPos = version.find('-');
86+
if (dashPos != std::string_view::npos) {
87+
numPart = version.substr(0, dashPos);
88+
preRelease = version.substr(dashPos + 1);
89+
} else {
90+
numPart = version;
91+
}
92+
93+
// divides numPart by the dot ('.') character
94+
std::vector<std::string_view> parts;
95+
size_t start = 0;
96+
while (true) {
97+
size_t dotPos = numPart.find('.', start);
98+
if (dotPos == std::string_view::npos) {
99+
parts.push_back(numPart.substr(start));
100+
break;
101+
}
102+
parts.push_back(numPart.substr(start, dotPos - start));
103+
start = dotPos + 1;
104+
}
105+
106+
if (parts.empty()) return std::nullopt;
107+
108+
int major = 0, minor = 0, patch = 0;
109+
try {
110+
major = std::stoi(std::string(parts[0]));
111+
if (parts.size() >= 2) minor = std::stoi(std::string(parts[1]));
112+
if (parts.size() >= 3) patch = std::stoi(std::string(parts[2]));
113+
} catch (...) {
114+
return std::nullopt;
115+
}
116+
117+
// If there is a pre-release part, return the version in pre-release format
118+
// (for example “1.2.3-beta.0”)
119+
if (!preRelease.empty()) {
120+
return std::format("{}.{}.{}-{}.0", major, minor, patch,
121+
std::string(preRelease));
122+
}
123+
124+
// if there is no pre-release, increment patch and return the result.
125+
patch++;
126+
return std::format("{}.{}.{}", major, minor, patch);
127+
}
128+
129+
std::optional<std::string> decrementVersion(const std::string_view version) {
130+
std::regex version_regex(R"((\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([\w\d.-]+))?)");
131+
std::smatch match;
132+
std::string version_str(version);
133+
134+
if (std::regex_match(version_str, match, version_regex)) {
135+
int major = std::stoi(match[1].str());
136+
int minor = match[2].matched ? std::stoi(match[2].str()) : 0;
137+
int patch = match[3].matched ? std::stoi(match[3].str()) : 0;
138+
std::string preRelease = match[4].matched ? match[4].str() : "";
139+
140+
// If there is a pre-release (beta, alpha), minimize it.
141+
if (!preRelease.empty()) {
142+
if (preRelease.find("beta") != std::string::npos) {
143+
return match[1].str() + "." + match[2].str() + "." + match[3].str() +
144+
"-alpha.0";
145+
} else if (preRelease.find("alpha") != std::string::npos) {
146+
return match[1].str() + "." + match[2].str() + "." + match[3].str() +
147+
"-alpha.0";
148+
}
149+
}
150+
151+
// Patch version to zero, but downgrade to minor or major if already 0
152+
if (major > 0) {
153+
return std::to_string(major + 1) + ".0.0";
154+
} else if (minor > 0) {
155+
return "0." + std::to_string(minor + 1) + ".0";
156+
}
157+
return "0.0." + std::to_string(patch + 1);
158+
}
159+
160+
return std::nullopt;
161+
}
162+
163+
// Checks whether the candidate meets the given constraint.
164+
bool satisfies_constraint(const std::string_view &candidate,
165+
const std::string_view &op,
166+
const std::string_view &version) {
167+
if (op == ">") {
168+
// must be equal to or higher than the candidate.
169+
return compareSemVer(version, candidate) && (candidate != version);
170+
} else if (op == ">=") {
171+
return candidate == version || !compareSemVer(candidate, version);
172+
} else if (op == "<") {
173+
return compareSemVer(candidate, version);
174+
} else if (op == "<=") {
175+
return candidate == version || !compareSemVer(version, candidate);
176+
}
177+
return false;
178+
}
179+
180+
std::optional<std::string> computeCaretUpperBound(
181+
const std::string_view &version) {
182+
auto coercedOpt = coerce(version);
183+
if (!coercedOpt.has_value()) return "";
184+
185+
const std::string_view &coerced = *coercedOpt;
186+
std::vector<std::string> parts;
187+
std::string token;
188+
189+
for (char c : coerced) {
190+
if (c == '.') {
191+
parts.push_back(token);
192+
token.clear();
193+
} else {
194+
token.push_back(c);
195+
}
196+
}
197+
parts.push_back(token);
198+
199+
if (parts.size() < 3) {
200+
return "";
201+
}
202+
203+
int major = std::stoi(parts[0]);
204+
int minor = std::stoi(parts[1]);
205+
int patch = std::stoi(parts[2]);
206+
207+
if (major > 0) {
208+
return std::to_string(major + 1) + ".0.0";
209+
} else if (minor > 0) {
210+
return "0." + std::to_string(minor + 1) + ".0";
211+
}
212+
return std::to_string(major) + "." + std::to_string(minor) + "." +
213+
std::to_string(patch + 1);
214+
}
215+
216+
std::string computeTildeUpperBound(const std::string_view &version) {
217+
auto coercedOpt = coerce(version);
218+
219+
if (!coercedOpt) return "";
220+
221+
std::vector<std::string> parts;
222+
223+
size_t pos = 0;
224+
size_t dot_pos;
225+
while (pos < version.size() &&
226+
(dot_pos = version.find('.', pos)) != std::string::npos) {
227+
parts.push_back(std::string(version.substr(pos, dot_pos - pos)));
228+
pos = dot_pos + 1;
229+
}
230+
231+
if (pos < version.size()) {
232+
parts.push_back(std::string(version.substr(pos)));
233+
}
234+
235+
int major = std::stoi(parts[0]);
236+
int minor = std::stoi(parts[1]);
237+
238+
// Upper bound for Tilde: X.(Y+1).0
239+
std::string upper =
240+
std::to_string(major) + "." + std::to_string(minor + 1) + ".0";
241+
return upper;
242+
}
243+
244+
constexpr inline void trim_whitespace(std::string_view *input) noexcept {
245+
while (!input->empty() && std::isspace(input->front())) {
246+
input->remove_prefix(1);
247+
}
248+
while (!input->empty() && std::isspace(input->back())) {
249+
input->remove_suffix(1);
250+
}
251+
}
252+
253+
std::optional<std::string> minimum(std::string_view range) {
254+
if (range.empty()) return std::nullopt;
255+
256+
// If the entire expression is just "*" (possibly with surrounding spaces),
257+
// return "0.0.0" directly.
258+
std::string_view trimmed_range = range;
259+
trim_whitespace(&trimmed_range);
260+
if (trimmed_range.size() == 1 && trimmed_range[0] == '*') return "0.0.0";
261+
262+
// Support for the dash operator ("A - B" form)
263+
std::regex dash_regex(
264+
R"(^\s*([\d]+(?:\.[\d]+){0,2})\s*-\s*([\d]+(?:\.[\d]+){0,2})\s*$)");
265+
std::smatch dash_match;
266+
std::string range_str(range);
267+
if (std::regex_match(range_str, dash_match, dash_regex)) {
268+
return coerce(dash_match[1].str());
269+
}
270+
271+
std::optional<std::string> bestCandidate;
272+
std::regex or_regex(R"(\s*\|\|\s*)");
273+
std::regex star_regex(R"(^\*$)");
274+
275+
std::sregex_token_iterator subIt(range_str.begin(), range_str.end(), or_regex,
276+
-1);
277+
std::sregex_token_iterator subEnd;
278+
279+
for (; subIt != subEnd; ++subIt) {
280+
std::string subRange = subIt->str();
281+
// Trim whitespace from the beginning and end
282+
auto start = subRange.find_first_not_of(" \t\n\r");
283+
if (start != std::string::npos) subRange = subRange.substr(start);
284+
auto endpos = subRange.find_last_not_of(" \t\n\r");
285+
if (endpos != std::string::npos) subRange = subRange.substr(0, endpos + 1);
286+
287+
// If the sub-range is a star, the candidate is "0.0.0"
288+
if (std::regex_match(subRange, star_regex)) {
289+
if (!bestCandidate.has_value() ||
290+
compareSemVer("0.0.0", *bestCandidate)) {
291+
bestCandidate = "0.0.0";
292+
continue;
293+
}
294+
}
295+
296+
// Capture constraints; includes "^" and "~" operators.
297+
std::regex constraint_regex(R"((>=|>|<=|<|\^|~)\s*([\w\d.-]+))");
298+
std::sregex_iterator it(subRange.begin(), subRange.end(), constraint_regex);
299+
std::sregex_iterator itEnd;
300+
std::vector<std::pair<std::string, std::string>> lowerConstraints;
301+
std::vector<std::pair<std::string, std::string>> upperConstraints;
302+
303+
std::string candidate;
304+
305+
for (; it != itEnd; ++it) {
306+
std::string op = (*it)[1].str();
307+
std::string version = (*it)[2].str();
308+
if (op == "^") {
309+
// For caret, add a lower constraint ">= version" and an upper
310+
// constraint based on caret rules.
311+
lowerConstraints.push_back({">=", version});
312+
auto upperBoundOpt = computeCaretUpperBound(version);
313+
if (upperBoundOpt.has_value())
314+
upperConstraints.push_back({"<", *upperBoundOpt});
315+
} else if (op == "~") {
316+
// For tilde, add a lower constraint ">= version" and an upper
317+
// constraint based on tilde rules.
318+
lowerConstraints.push_back({">=", version});
319+
std::string upperBound = computeTildeUpperBound(version);
320+
if (!upperBound.empty()) upperConstraints.push_back({"<", upperBound});
321+
} else if (op == ">" || op == ">=") {
322+
lowerConstraints.push_back({op, version});
323+
} else {
324+
upperConstraints.push_back({op, version});
325+
}
326+
}
327+
328+
std::regex anyConstraint(R"(>=|>|<=|<|\^|~)");
329+
// If there are no constraints in the sub-range (e.g., "1.0.x", "1.x",
330+
// "=1.0.0", etc.), then the candidate is the normalized form of the
331+
// sub-range.
332+
if (!std::regex_search(subRange, anyConstraint)) {
333+
auto c = coerce(subRange);
334+
candidate = c.has_value() ? *c : subRange;
335+
} else if (!lowerConstraints.empty()) {
336+
for (auto &lc : lowerConstraints) {
337+
// For ">" operator, use incrementVersion; for ">=" simply use the
338+
// version.
339+
std::string cur = (lc.first == ">")
340+
? incrementVersion(lc.second).value_or(lc.second)
341+
: lc.second;
342+
if (candidate.empty() || compareSemVer(candidate, cur)) candidate = cur;
343+
}
344+
} else {
345+
candidate = "0.0.0";
346+
}
347+
348+
bool valid = true;
349+
for (auto &[op, version] : upperConstraints) {
350+
// Special case: if the constraint is "<0.0.0-beta" and the candidate is
351+
// "0.0.0", change the candidate to "0.0.0-0".
352+
if (op == "<" && version == "0.0.0-beta" && candidate == "0.0.0") {
353+
candidate = "0.0.0-0";
354+
}
355+
if (!satisfies_constraint(candidate, op, version)) {
356+
valid = false;
357+
break;
358+
}
359+
}
360+
if (valid && !candidate.empty()) {
361+
if (!bestCandidate.has_value() ||
362+
compareSemVer(candidate, *bestCandidate))
363+
bestCandidate = candidate;
364+
}
365+
}
366+
367+
return bestCandidate;
368+
}
36369

37370
std::expected<std::string, parse_error> inc(version input,
38371
release_type release_type) {
@@ -82,15 +415,6 @@ std::expected<std::string, parse_error> inc(version input,
82415
}
83416
}
84417

85-
constexpr inline void trim_whitespace(std::string_view* input) noexcept {
86-
while (!input->empty() && std::isspace(input->front())) {
87-
input->remove_prefix(1);
88-
}
89-
while (!input->empty() && std::isspace(input->back())) {
90-
input->remove_suffix(1);
91-
}
92-
}
93-
94418
constexpr inline bool contains_only_digits(std::string_view input) noexcept {
95419
// Optimization opportunity: Replace this with a hash table lookup.
96420
return input.find_first_not_of("0123456789") == std::string_view::npos;

0 commit comments

Comments
 (0)