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)
|
## Task 2: Config Loading (src/tofu/config.d)
|
||||||
|
|
||||||
### toml package API (v1.0.0, Kripth/toml)
|
### 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