feat(version): port rpm-style version comparison from ZETA
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
/// tofu.vercmp — RPM-style version comparison and dependency constraint checking.
|
||||
///
|
||||
/// Direct port of ZETA's `vercmp.lua` (references/ZETA/lib/vercmp.lua).
|
||||
/// Splits version strings into alternating digit and letter segments;
|
||||
/// digit segments compare numerically (leading zeros ignored),
|
||||
/// letter segments compare lexically.
|
||||
/// Shorter versions (fewer segments) are considered older.
|
||||
///
|
||||
/// Note: named `vercmp` rather than `version` because `version` is a
|
||||
/// reserved keyword in D.
|
||||
module tofu.vercmp;
|
||||
|
||||
import tofu.types;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@safe @nogc pure nothrow
|
||||
bool isDigit(char c) { return c >= '0' && c <= '9'; }
|
||||
|
||||
@safe @nogc pure nothrow
|
||||
bool isLetter(char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Segment extraction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads the next digit or letter segment from `s` starting at index `i`,
|
||||
/// skipping separator characters (anything not digit/letter).
|
||||
/// Sets `seg` and `ni` (next index). Returns `true` if a segment was found.
|
||||
@safe pure
|
||||
bool nextSegment(scope const string s, scope size_t i,
|
||||
out string seg, out size_t ni) {
|
||||
while (i < s.length) {
|
||||
if (isDigit(s[i]) || isLetter(s[i]))
|
||||
break;
|
||||
i++;
|
||||
}
|
||||
if (i >= s.length) {
|
||||
seg = null;
|
||||
ni = i;
|
||||
return false;
|
||||
}
|
||||
bool digit = isDigit(s[i]);
|
||||
size_t j = i + 1;
|
||||
while (j < s.length) {
|
||||
if (digit && !isDigit(s[j]))
|
||||
break;
|
||||
if (!digit && !isLetter(s[j]))
|
||||
break;
|
||||
j++;
|
||||
}
|
||||
seg = s[i .. j];
|
||||
ni = j;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Numeric segment comparison
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compares two numeric segments.
|
||||
/// Leading zeros are stripped before comparison.
|
||||
/// Longer stripped string wins; equal-length strings compare lexicographically.
|
||||
@safe pure
|
||||
int cmpNumeric(scope const string a, scope const string b) {
|
||||
// Strip leading zeros (Lua: gsub("^0+", ""), fallback to "0")
|
||||
size_t ai = 0;
|
||||
while (ai < a.length && a[ai] == '0')
|
||||
ai++;
|
||||
string x = ai < a.length ? a[ai .. $] : "0";
|
||||
|
||||
size_t bi = 0;
|
||||
while (bi < b.length && b[bi] == '0')
|
||||
bi++;
|
||||
string y = bi < b.length ? b[bi .. $] : "0";
|
||||
|
||||
if (x.length > y.length) return 1;
|
||||
if (x.length < y.length) return -1;
|
||||
if (x > y) return 1;
|
||||
if (x < y) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Whitespace helper
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Remove all whitespace from a string. Iterates raw code units so
|
||||
/// it only strips ASCII whitespace (space, tab, newline, carriage return).
|
||||
/// This matches Lua's `gsub("%s+", "")` for version strings.
|
||||
@safe
|
||||
string removeWhitespace(string s) {
|
||||
size_t newLen = 0;
|
||||
for (size_t i = 0; i < s.length; i++) {
|
||||
char c = s[i];
|
||||
if (c != ' ' && c != '\t' && c != '\n' && c != '\r')
|
||||
newLen++;
|
||||
}
|
||||
if (newLen == s.length)
|
||||
return s; // no whitespace — return original
|
||||
char[] buf = new char[newLen];
|
||||
size_t pos = 0;
|
||||
for (size_t i = 0; i < s.length; i++) {
|
||||
char c = s[i];
|
||||
if (c != ' ' && c != '\t' && c != '\n' && c != '\r')
|
||||
buf[pos++] = c;
|
||||
}
|
||||
// Safe: buf is freshly allocated, no other references
|
||||
return (() @trusted => cast(string) buf)();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Version comparison
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compares two version strings using RPM-style semantics.
|
||||
///
|
||||
/// Returns -1 if a < b, 0 if a == b, 1 if a > b.
|
||||
///
|
||||
/// Whitespace is stripped from both inputs. Versions are split into
|
||||
/// alternating digit and letter segments (separators like `.`, `-`, `_`,
|
||||
/// `~` are skipped). Digit segments compare numerically (leading zeros
|
||||
/// ignored), letter segments compare lexically. When one version runs
|
||||
/// out of segments, the shorter version is considered older.
|
||||
@safe
|
||||
int compare(string a, string b) {
|
||||
a = removeWhitespace(a);
|
||||
b = removeWhitespace(b);
|
||||
|
||||
size_t ia = 0;
|
||||
size_t ib = 0;
|
||||
|
||||
while (true) {
|
||||
string sa;
|
||||
string sb;
|
||||
size_t na;
|
||||
size_t nb;
|
||||
bool hasA = nextSegment(a, ia, sa, na);
|
||||
bool hasB = nextSegment(b, ib, sb, nb);
|
||||
|
||||
if (!hasA || !hasB) {
|
||||
if (!hasA && !hasB) return 0; // both exhausted → equal
|
||||
if (!hasA) return -1; // a exhausted first → a older
|
||||
return 1; // b exhausted first → a newer
|
||||
}
|
||||
|
||||
ia = na;
|
||||
ib = nb;
|
||||
|
||||
int c;
|
||||
if (isDigit(sa[0]) && isDigit(sb[0]))
|
||||
c = cmpNumeric(sa, sb);
|
||||
else {
|
||||
if (sa < sb) c = -1;
|
||||
else if (sa > sb) c = 1;
|
||||
else c = 0;
|
||||
}
|
||||
|
||||
if (c != 0) return c;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Dependency parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parses a dependency specification string into a `DepConstraint`.
|
||||
///
|
||||
/// Examples:
|
||||
/// "libffi" → DepConstraint("libffi", DepOp.none, "")
|
||||
/// "pcre2>=10.42" → DepConstraint("pcre2", DepOp.ge, "10.42")
|
||||
/// "x=1.0" → DepConstraint("x", DepOp.eq, "1.0") ("=" normalized)
|
||||
///
|
||||
/// Throws `TypesException` or `Exception` on malformed input.
|
||||
///
|
||||
/// Delegates to `DepConstraint.parse` which provides the canonical
|
||||
/// implementation (already ported from ZETA in types.d by task 4).
|
||||
@safe
|
||||
DepConstraint parseDep(scope const string spec) {
|
||||
return DepConstraint.parse(spec);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constraint satisfaction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Checks whether `installed` version satisfies the given `constraint`.
|
||||
///
|
||||
/// When `constraint.op` is `DepOp.none` (unconstrained), always returns
|
||||
/// `true`. Otherwise compares `installed` against `constraint.ver` using
|
||||
/// the operator semantics:
|
||||
///
|
||||
/// DepOp.ge → compare >= 0 DepOp.le → compare <= 0
|
||||
/// DepOp.eq → compare == 0 DepOp.ne → compare != 0
|
||||
/// DepOp.gt → compare > 0 DepOp.lt → compare < 0
|
||||
@safe
|
||||
bool satisfies(scope const string installed, scope const DepConstraint constraint) {
|
||||
int c;
|
||||
final switch (constraint.op) {
|
||||
case DepOp.none:
|
||||
return true;
|
||||
case DepOp.ge:
|
||||
c = compare(installed, constraint.ver);
|
||||
return c >= 0;
|
||||
case DepOp.le:
|
||||
c = compare(installed, constraint.ver);
|
||||
return c <= 0;
|
||||
case DepOp.eq:
|
||||
c = compare(installed, constraint.ver);
|
||||
return c == 0;
|
||||
case DepOp.ne:
|
||||
c = compare(installed, constraint.ver);
|
||||
return c != 0;
|
||||
case DepOp.gt:
|
||||
c = compare(installed, constraint.ver);
|
||||
return c > 0;
|
||||
case DepOp.lt:
|
||||
c = compare(installed, constraint.ver);
|
||||
return c < 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Unittests — ported from references/ZETA/tests/suites/vercmp.lua
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@safe unittest {
|
||||
// Equal versions
|
||||
assert(compare("1.0", "1.0") == 0);
|
||||
assert(compare("1.0.0", "1.0.0") == 0);
|
||||
assert(compare("2.3.4", "2.3.4") == 0);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Numeric ordering
|
||||
assert(compare("1.10", "1.9") > 0);
|
||||
assert(compare("2.0", "1.99") > 0);
|
||||
assert(compare("1.0", "0.99") > 0);
|
||||
assert(compare("1.1", "1.10") < 0);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Extra segments win (rpm-style): longer is newer
|
||||
assert(compare("1.0.0", "1.0") > 0);
|
||||
assert(compare("1.0rc1", "1.0") > 0);
|
||||
assert(compare("1.0a", "1.0") > 0);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Letter segments compare lexically
|
||||
assert(compare("1.0beta", "1.0alpha") > 0);
|
||||
assert(compare("1.0alpha", "1.0beta") < 0);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Whitespace is ignored
|
||||
assert(compare(" 1.0 ", "1.0") == 0);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Leading zeros are ignored in digit segments
|
||||
assert(compare("01.05", "1.5") == 0);
|
||||
assert(compare("1.000", "1.0") >= 0);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Empty and single-segment strings
|
||||
assert(compare("", "") == 0);
|
||||
assert(compare("", "1") == -1);
|
||||
assert(compare("1", "") == 1);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// parseDep: plain name (unconstrained)
|
||||
auto d = parseDep("libffi");
|
||||
assert(d.name == "libffi");
|
||||
assert(d.op == DepOp.none);
|
||||
assert(d.ver == "");
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// parseDep: constrained with operator
|
||||
auto d1 = parseDep("pcre2>=10.42");
|
||||
assert(d1.name == "pcre2");
|
||||
assert(d1.op == DepOp.ge);
|
||||
assert(d1.ver == "10.42");
|
||||
|
||||
// Whitespace around operator
|
||||
auto d2 = parseDep("pcre2 >= 10.42");
|
||||
assert(d2.name == "pcre2");
|
||||
assert(d2.op == DepOp.ge);
|
||||
assert(d2.ver == "10.42");
|
||||
|
||||
auto d3 = parseDep("glib<=2.8");
|
||||
assert(d3.name == "glib");
|
||||
assert(d3.op == DepOp.le);
|
||||
assert(d3.ver == "2.8");
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// Single equals becomes == (Perl-style)
|
||||
auto d = parseDep("x=1.0");
|
||||
assert(d.op == DepOp.eq);
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// parseDep rejects garbage
|
||||
bool threw;
|
||||
|
||||
threw = false;
|
||||
try { parseDep("!!!"); } catch (Exception) { threw = true; }
|
||||
assert(threw, "expected parseDep(\"!!!\") to throw");
|
||||
|
||||
threw = false;
|
||||
try { parseDep("foo bar baz"); } catch (Exception) { threw = true; }
|
||||
assert(threw, "expected parseDep(\"foo bar baz\") to throw");
|
||||
|
||||
threw = false;
|
||||
try { parseDep(""); } catch (Exception) { threw = true; }
|
||||
assert(threw, "expected parseDep(\"\") to throw");
|
||||
}
|
||||
|
||||
@safe unittest {
|
||||
// satisfies: all operators matched against installed version
|
||||
assert(satisfies("10.42", DepConstraint("", DepOp.ge, "10.42")));
|
||||
assert(satisfies("10.43", DepConstraint("", DepOp.ge, "10.42")));
|
||||
assert(!satisfies("10.2", DepConstraint("", DepOp.ge, "10.42")));
|
||||
|
||||
assert(satisfies("10.41", DepConstraint("", DepOp.le, "10.42")));
|
||||
|
||||
assert(satisfies("10.42", DepConstraint("", DepOp.eq, "10.42")));
|
||||
assert(!satisfies("10.43", DepConstraint("", DepOp.eq, "10.42")));
|
||||
|
||||
assert(satisfies("10.43", DepConstraint("", DepOp.ne, "10.42")));
|
||||
|
||||
assert(satisfies("10.43", DepConstraint("", DepOp.gt, "10.42")));
|
||||
|
||||
assert(satisfies("10.41", DepConstraint("", DepOp.lt, "10.42")));
|
||||
|
||||
// Unconstrained (DepOp.none) — always satisfied
|
||||
assert(satisfies("anything", DepConstraint("", DepOp.none, "")));
|
||||
}
|
||||
Reference in New Issue
Block a user