feat(version): port rpm-style version comparison from ZETA
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
### toml package API (v1.0.0, Kripth/toml)
|
||||
|
||||
@@ -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