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
+35
View File
@@ -0,0 +1,35 @@
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
Warning Excluding main source file src/main.d from test.
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
Warning: malformed TOML config at /tmp/tofu-test-config-bad-178345.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
4 modules passed unittests
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+40
View File
@@ -6,6 +6,46 @@ _Auto-scaffolded by /start-work. Append new entries below - never overwrite._
--- ---
## Task 5 — tofu.vercmp (RPM-style version comparison)
### Module naming
- `version` is a D reserved keyword (conditional compilation). Module is named
`tofu.vercmp`, file `src/tofu/vercmp.d`. The `DepConstraint` struct in
`types.d` uses field `ver` (not `version`) for the same reason.
### Algorithm (exact port from ZETA/lib/vercmp.lua)
- `nextSegment`: skips any non-digit/non-letter chars (separators); then reads
a contiguous run of same-type chars. Returns `(segment, nextIndex)`.
- `cmpNumeric`: strips leading zeros from both segments (`"007"` → `"7"`,
`"000"` → `"0"`). Longer stripped string wins; then lexicographic.
- `compare`: strips all whitespace, then loops over alternating segments.
If both segments are digit-typed → `cmpNumeric`, else lexical. When one
version exhausts segments: the exhausted one is *older* (shorter = older).
This matches RPM semantics: `compare("1.0.0", "1.0") > 0`.
- `satisfies`: uses `final switch` on `DepOp` enum. Maps to `compare` result
as expected (ge→c>=0, le→c<=0, eq→c==0, ne→c!=0, gt→c>0, lt→c<0).
- `parseDep`: delegates to `DepConstraint.parse` (already ported in types.d by
task 4). Both implementations are kept consistent.
### whitespace stripping
- Could not use `filter`+`array` because Phobos auto-decodes `string` to
`dchar` range elements, returning `dchar[]` not `string`. Implemented a
manual `removeWhitespace` helper using raw code-unit indexing, with a
`@trusted` cast since the freshly-allocated `char[]` has no aliasing.
### Test porting
- All 10 ZETA test categories ported as separate `@safe unittest` blocks.
- 4 modules pass unittests (types, vercmp, plus pre-existing ones).
- `dub build` passes with warnings-as-errors enabled.
### Future considerations
- When task 12 (constraint resolution) imports `tofu.vercmp`, the `compare`
and `satisfies` functions are ready. The `parseDep` integration with
`DepConstraint.parse` ensures consistency between ZETA and tofu's dep
constraint semantics.
---
## Task 2: Config Loading (src/tofu/config.d) ## Task 2: Config Loading (src/tofu/config.d)
### toml package API (v1.0.0, Kripth/toml) ### toml package API (v1.0.0, Kripth/toml)
+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, "")));
}