feat(version): port rpm-style version comparison from ZETA

This commit is contained in:
2026-08-08 17:22:06 -04:00
parent 348f0e3bac
commit c545879115
3 changed files with 421 additions and 0 deletions
+346
View File
@@ -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, "")));
}