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
79namespace version_weaver {
810bool 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
37370std::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-
94418constexpr 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