diff --git a/.omo/evidence/task-5-tofu-core.log b/.omo/evidence/task-5-tofu-core.log new file mode 100644 index 0000000..1329db0 --- /dev/null +++ b/.omo/evidence/task-5-tofu-core.log @@ -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 diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index 2d4ff47..118d51e 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -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) diff --git a/src/tofu/vercmp.d b/src/tofu/vercmp.d new file mode 100644 index 0000000..301d3bc --- /dev/null +++ b/src/tofu/vercmp.d @@ -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, ""))); +}