Skip to content

AcceptedOwner sparkles:versions

sparkles:versions — Specification ​

Abstract ​

sparkles:versions is a D library that parses, orders, and constrains the version strings of many package ecosystems — Semantic Versioning, Python, Maven, Debian, calendar versions, and others — and reads the cross-ecosystem Package URL and VERS notations. Each ecosystem keeps its own grammar and ordering rules, so a version is never compared by rules that are not its own. Versions from different ecosystems cannot be ordered against each other: the mistake is rejected at compile time, and a collection that mixes ecosystems gets an explicit "no order" instead of a guess. Parsing never throws; it returns the value or a structured error with the byte offset of the failure.

Introduction ​

Tools that handle software from more than one ecosystem keep asking the same questions: is this version newer than that one, does it fall inside this range, which release is the latest? A vulnerability scanner matches a Python package against an advisory, a release tool picks the next tag, and an inventory of installed packages spans Debian, Python, and Maven at once. Every ecosystem answers these questions with its own grammar. In Semantic Versioning 1.0.0-rc.1 precedes 1.0.0; Python spells the same idea 1.0rc1; Debian sorts 1.0~beta1 before 1.0 and lets an epoch such as 2: override everything after it.

Comparing such strings by one universal rule gives answers that look plausible and are quietly wrong, while supporting a single ecosystem leaves every other one unhandled. Ranges are harder still: each ecosystem writes them in its own syntax, and SemVer-style ranges add the rule that a prerelease belongs to a range only when the range itself names a prerelease of the same release. The Package URL and VERS standards give versions and ranges a portable spelling, but a portable spelling still needs each ecosystem's rules to mean anything.

The library therefore models each ecosystem as its own type — a version scheme — that meets one small contract: its values are totally ordered and render back to text. Every further ability, such as a compact integer sort key, prerelease handling, or named numeric components, is optional and detected at compile time, in the style of Design by Introspection. Generic algorithms take a faster path when an ability is present and produce the same result without it. A range is one generic set of intervals, whatever the scheme; native range syntax and VERS both translate into it. Because two schemes are two distinct types, ordering versions across schemes does not compile. A collection that mixes schemes holds its versions in a tagged union, whose comparison returns an order for two versions of the same scheme and an explicit null for two of different schemes.

This specification defines the contract a scheme meets, the generic range and its operations, parsing, and the Package URL and VERS interop. It does not resolve dependencies; a solver belongs in a separate library built on the range algebra. It provides no helpers that edit a version, such as bumping one, and no total order across ecosystems. A program may define its own scheme type and use it directly, but the mixed-scheme union and the resolution of an ecosystem named in a Package URL or VERS string cover only the built-in schemes, and nothing is registered at run time. Package URLs are parsed, never generated. Some ecosystems are not covered, notably those that write a prerelease without a separator, as Go's go1.22rc1 does. Each such grammar needs a parser of its own; Python's 1.0rc1 is supported because the Python scheme has one.

Section 1 states the contract at a glance and §2 the package layout. Section 3 defines the version concept, §4 the range concept, §5 the generic operations, §6 the scheme concept, and §7 parsing. Section 8 surveys the shipped schemes, §§9–11 cover VERS, Package URL, and mixed-scheme values, and §12 lists the public surface. Delivery order and deferred work live in PLAN.md, the reasoning and prior art behind each decision in the explanation guides, and per-scheme grammars, examples, and provenance in the scheme catalogue.

1. Overview ​

Each ecosystem is one hand-written struct (SemVer, PypiVersion, DebianVersion, …) that conforms to the compile-time concept isVersion!T and, where it parses an ecosystem's strings, isVersionScheme!S. Generic algorithms — Ranges!V, satisfies, sort — operate over any conforming type.

Core rules:

  • A scheme is a plain struct plus static assert(isVersion!S && isVersionScheme!S);. There is no base class, no registration step, and no code generation.
  • Optional capabilities (§3.2) are opt-in per struct. Their absence is never an error; a generic algorithm falls back to the required surface.
  • Cross-scheme comparison does not compile: SemVer and PypiVersion are distinct nominal types with no shared opCmp. Hold mixed-scheme values in AnyVersion and compare them with the partial compareAny.

A consumer who needs only SemVer imports one type:

d
import sparkles.versions.schemes.semver : SemVer;

auto a = SemVer.parse("1.2.3-rc.1").value;
auto b = SemVer.parse("1.2.3").value;
assert(a < b);                       // prerelease precedes its release
assert(a.toString == "1.2.3-rc.1");

2. Package and module layout ​

IdentifierValue
Dub sub-packagesparkles:versions
Source rootlibs/versions/src/sparkles/versions/
Package modulesparkles.versions

The folder uses the plural name versions/ because version is a D keyword.

ModuleContents
sparkles.versionsPublic re-exports (package.d)
sparkles.versions.traitsisVersion!T, isVersionRange!R, isVersionScheme!S + optional-capability traits
sparkles.versions.parsingParseMode; re-exports the parse types from base.text.errors
sparkles.versions.rangesRanges!V (sorted disjoint intervals)
sparkles.versions.operationsorder, sort, satisfies, caret, tilde
sparkles.versions.versVERS URI parser/emitter + compile-time scheme registry
sparkles.versions.purlPackage URL parser + purl-type → scheme mapping
sparkles.versions.anyAnyVersion / AnyRange sum types, compareAny
sparkles.versions.schemesRe-exports every scheme module + registry hook
sparkles.versions.schemes.semverSemVer (strict SemVer 2.0.0)
sparkles.versions.schemes.dmdDmd (3-digit zero-padded minor)
sparkles.versions.schemes.dmd_compactDmdCompact (4-byte bitfield-encoded prerelease)
sparkles.versions.schemes.tinyTiny (4-byte, no prerelease)
sparkles.versions.schemes.calver_yymmCalVerYYMM (Ubuntu 24.04.1)
sparkles.versions.schemes.calver_yyyymmddCalVerYYYYMMDD (Arch 2024.05.01)
sparkles.versions.schemes.vimVimVer (4-digit patch)
sparkles.versions.schemes.pypiPypiVersion (PEP 440)
sparkles.versions.schemes.mavenMavenVersion (qualifier order)
sparkles.versions.schemes.debDebianVersion (epoch/upstream/revision)
sparkles.versions.schemes.genericGeneric (opaque lexicographic baseline)
sparkles.versions.testingversion(unittest) test helpers (checkParse, checkRoundTrip, …)

The library has no _internal package: shared low-level primitives live in sparkles:base, and the SemVer identifier grammar (compareSemVerPrerelease, identifier validation) lives package-scoped in schemes/semver.d, reused by the other SemVer-shaped schemes.

Foundation in sparkles:base — the parsing/formatting primitives are generic and live in base, not in versions:

ModuleProvides
sparkles.base.text.errorsParseError {code, offset}, ParseErrorCode, ParseExpected!T (generic)
sparkles.base.text.readersreadInteger, skipWhile, tryConsume, readUntil (slice-advance, @nogc)
sparkles.base.text.writerswriteIntegerPadded (alongside the existing writeInteger)

3. The Version concept ​

A version is totally ordered and renders to text. That is the entire required surface; everything else is optional (§3.2).

3.1 Required surface — isVersion!T ​

isVersion!T (in sparkles.versions.traits) requires a three-way opCmp and an output-range toString. Named sub-checks report which half is missing:

d
template isVersion(T)
{
    enum hasOpCmp    = is(typeof((const T a, const T b) => a.opCmp(b)) : int);
    enum hasToString = __traits(compiles, (const T v) {
        void delegate(scope const(char)[]) @safe sink;
        v.toString(sink);                 // exact output-range call
    });
    enum isVersion = hasOpCmp && hasToString;
}

A conforming struct provides:

d
int  opCmp(in T other) const @safe pure nothrow @nogc;  // three-way total order
void toString(W)(ref W sink) const;                      // writes into an output range
  • Provide opEquals and toHash consistent with opCmp so versions work as associative-array keys and in ==.
  • toString writes into an output range, per the Sparkles conventions in AGENTS.md.

3.2 Optional capability vocabulary ​

Each capability is an independently-detectable trait in sparkles.versions.traits. A type that provides one enables a fast path or an extra feature; a type that omits one still works through the required surface.

CapabilityDetection ruleBehavioural impact
hasOrderKey!T.orderKey → any unsigned int (ubyte…ulong)radix sort, compact Ranges!T bounds, fast opCmp pre-check
supportsPrerelease!T.isPrerelease → boolprerelease-in-range rule (gates satisfies)
hasComponents!Tenum string[] components of named uint fieldsgeneric component iteration/compare, truncateTo
hasSemVerComponents!Tcomponents begins ["major","minor","patch"]caret ^ / tilde ~ range operators
hasBuildMetadata!T.build → const(char)[]build-aware compare
d
import std.traits : isUnsigned;

/// Monotonic unsigned-integer key of any width (`ubyte` … `ulong`): the
/// scheme picks the narrowest type that fits its components, so a compact
/// scheme can expose a `uint` (or smaller) key for narrower comparisons
/// and tighter `Ranges!T` bound storage. Where present,
/// `sign(a.orderKey <=> b.orderKey) == sign(a <=> b)` whenever the keys
/// differ; equal keys fall through to `opCmp`. (`isUnsigned` excludes
/// `bool` and the character types, so a stray `bool`/`char` member does
/// not accidentally qualify.)
enum hasOrderKey(T) = isUnsigned!(typeof(T.init.orderKey));

/// The unsigned integer type a scheme's `orderKey` returns — `uint` for a
/// 4-byte scheme, `ulong` for SemVer. Only valid when `hasOrderKey!T`;
/// generic code uses it to size compact key storage.
alias OrderKeyType(T) = typeof(T.init.orderKey);

enum supportsPrerelease(T) = is(typeof((const T v) => v.isPrerelease) : bool);

/// A version exposing an ordered list of named numeric components.
/// `T.components` is a compile-time `string[]` of readable unsigned-int
/// member names, most-significant first (the order `opCmp` compares and
/// `toString` prints them in). Arity is free: 3 for SemVer, 4 for .NET /
/// Windows, `["year","month","day"]` for CalVer. Generic code iterates
/// the list to compare, truncate, and bucket without hardcoding names.
template hasComponents(T)
{
    static if (is(typeof(T.components) : const(string)[]))
        enum hasComponents = T.components.length >= 1 && allComponentsUnsigned!T;
    else
        enum hasComponents = false;
}

private enum bool allComponentsUnsigned(T) = () {
    bool ok = true;
    static foreach (name; T.components)
        static if (!__traits(hasMember, T, name)
                || !isUnsigned!(typeof(__traits(getMember, T.init, name))))
            ok = false;
    return ok;
}();

/// True when the list begins with the SemVer triple, so caret `^` / tilde
/// `~` have their conventional "compatible within major/minor" meaning. A
/// 4-component or calendar scheme has `hasComponents` but not this, so it
/// correctly gets no caret operator.
enum hasSemVerComponents(T) =
    hasComponents!T && T.components.length >= 3
    && T.components[0] == "major"
    && T.components[1] == "minor"
    && T.components[2] == "patch";

enum hasBuildMetadata(T) = is(typeof((const T v) => v.build) : const(char)[]);

The component list drives three generic helpers (in sparkles.versions.traits) that schemes reuse and generic algorithms call:

  • compareComponents(a, b) — walks the list most-significant-first for the numeric part of opCmp.
  • componentAt(v, i) — reads the i-th component as a ulong.
  • componentCount!T — T.components.length.

The list carries names and order only. Per-component zero-pad width stays in each scheme's own toString.

Two rules govern optional capabilities:

  • All-or-nothing. Expose a capability only when it holds for every value of the type — never one that works sometimes. A scheme whose components can overflow its chosen orderKey width omits orderKey entirely; to gain headroom, it returns a wider unsigned type.
  • Equivalence. A capability's fast path produces the same result as the required-surface fallback. orderKey satisfies sign(a.orderKey <=> b.orderKey) == sign(a.opCmp(b)) whenever the keys differ, and the test suite asserts this across each scheme's corpus.

Generic (§8) is the baseline scheme: an opaque, lexicographically compared version with zero optional capabilities. Every generic algorithm's fallback path runs against it.

4. The Range concept ​

A version range is a set of versions, expressed as set algebra. Ranges!V is the single concrete implementation.

4.1 Required surface — isVersionRange!R ​

Five members are required — the minimal set-algebra basis. full, union_, isDisjoint, and subsetOf are derived by default and need not be hand-written:

d
template isVersionRange(R)
{
    enum isVersionRange =
        is(R.Version) && isVersion!(R.Version) &&
        is(typeof(R.empty()) == R) &&
        is(typeof(R.singleton(R.Version.init)) == R) &&
        is(typeof((const R r) => r.complement()) : R) &&
        is(typeof((const R a, const R b) => a.intersection(b)) : R) &&
        is(typeof((const R r, const R.Version v) => r.contains(v)) : bool);
}
MethodStatusMeaning
static empty()requiredthe empty set
static singleton(V v)requiredthe set {v}
complement()requiredset complement
intersection(in R other)requiredset intersection
contains(in V v)requiredmembership test
static full()defaultedempty().complement()
union_(in R other)defaultedcomplement().intersection(other.complement()).complement()
isDisjoint(in R other)defaultedintersection(other) == empty()
subsetOf(in R other)defaultedthis == intersection(other)

4.2 The concrete type — Ranges!V ​

Ranges!V (in sparkles.versions.ranges) is the only generic data structure in the library. It stores a sorted, disjoint sequence of intervals and maintains those invariants on every operation: segments stay sorted, no segment is empty, and adjacent mergeable intervals coalesce.

d
struct Ranges(V) if (isVersion!V)
{
    alias Version = V;

    // --- required set-algebra basis ---
    static Ranges empty();
    static Ranges singleton(V v);
    Ranges complement() const;
    Ranges intersection(in Ranges other) const;
    bool contains(in V v) const;

    // --- defaulted via De Morgan ---
    static Ranges full() => empty().complement();
    Ranges union_(in Ranges other) const
        => complement().intersection(other.complement()).complement();
    bool isDisjoint(in Ranges other) const => intersection(other) == empty();
    bool subsetOf(in Ranges other) const => this == intersection(other);

    // --- interval conveniences ---
    static Ranges higherThan(V v);          // [v, +∞)
    static Ranges strictlyHigherThan(V v);  // (v, +∞)
    static Ranges lowerThan(V v);           // (-∞, v]
    static Ranges strictlyLowerThan(V v);   // (-∞, v)
    static Ranges between(V lo, V hi);      // [lo, hi)

    // --- equality / formatting ---
    bool opEquals(in Ranges other) const;
    void toString(W)(ref W w) const;        // emits VERS constraint syntax
}

Rules:

  • There is no per-scheme range type — no NpmRange, PypiRange, or DebianRange. Each scheme's Range alias is Ranges!ThatVersion.
  • The native range grammar (^1.2.0 for npm, [1.0,2.0) for Maven, >=1.2.4 for PEP 440) and the vers: constraint syntax are static methods on the scheme struct (§6, §9), not on Ranges.
  • opEquals compares canonical (sorted, merged) interval sequences, so two ranges built from different but equivalent expressions compare equal.
  • toString emits VERS constraint syntax (§9), giving every range a scheme-agnostic textual form.

5. Operations ​

Generic operations live in sparkles.versions.operations (with the component helpers in sparkles.versions.traits and the range algebra in sparkles.versions.ranges). Each pairs a baseline that needs only the required surface with an opt-in fast path gated on an optional capability.

5.1 order — fast-path / fallback compare ​

order(a, b) returns the same three-way result as a.opCmp(b) for any isVersion!T. When T provides hasOrderKey, compare the keys first and fall through to opCmp only on a key tie:

d
int order(T)(in T a, in T b) @safe pure nothrow @nogc
if (isVersion!T)
{
    static if (hasOrderKey!T)
    {
        const ka = a.orderKey, kb = b.orderKey;
        if (ka != kb)
            return ka < kb ? -1 : 1;   // keys differ → decisive
    }
    return a.opCmp(b);                  // fallback / tie-break
}

Generic, lacking orderKey, always takes the fallback branch.

5.2 satisfies — version-in-range, prerelease-gated ​

satisfies(v, range) reports whether a version is admitted by a range. The base case is range.contains(v). When T provides supportsPrerelease and hasSemVerComponents, apply the prerelease-in-range rule:

A prerelease version satisfies a range only when at least one comparator in the range names a prerelease of the same (major, minor, patch) triple.

The rule is defined over the (major, minor, patch) triple, so it requires hasSemVerComponents too: a prerelease-capable scheme that lacks that triple (MavenVersion) falls back to plain contains.

Given the range >=1.2.0:

  • 1.3.0 satisfies it (a stable release ≥ the bound).
  • 1.3.0-beta.1 does not satisfy it — even though it is numerically inside [1.2.0, +∞) — because no comparator in >=1.2.0 names a prerelease of the 1.3.0 triple.
  • 1.2.0-beta.1 satisfies >=1.2.0-alpha, because that comparator names a prerelease of the same 1.2.0 triple.

When T does not provide supportsPrerelease (e.g. Tiny, Generic), satisfies reduces to contains — the rule is statically inert for schemes that do not model prereleases.

5.3 Caret / tilde — gated on hasSemVerComponents ​

The npm-style operators desugar to Ranges!V intervals using the (major, minor, patch) triple, so they require hasSemVerComponents!T (not merely hasComponents, since ^/~ are undefined for a 4-component or calendar scheme):

  • ^1.2.3 → [1.2.3, 2.0.0) (compatible within the major).
  • ~1.2.3 → [1.2.3, 1.3.0) (compatible within the minor).

They are scheme-level static helpers (SemVer.caret(v), SemVer.tilde(v)). Calling one on a scheme without the SemVer triple is a compile-time error:

d
static assert(hasSemVerComponents!V,
    "caret/tilde require components beginning [\"major\",\"minor\",\"patch\"] "
    ~ "(hasSemVerComponents!V). " ~ V.stringof
    ~ " has no SemVer triple; build the range explicitly instead.");

5.4 sort ​

sort(versions) orders a slice of isVersion!T. With hasOrderKey it may radix-sort on the keys (resolving key ties with opCmp); without it it comparison-sorts via opCmp. Both paths produce the same ordering.

5.5 truncateTo ​

truncateTo!"name"(v) returns a version of the same type with every component below the named one (in components order) zeroed — useful for bucketing (group SemVer by major.minor, group a CalVer by "month"). The name must appear in T.components, so it requires hasComponents!T (any arity) and is a compile-time error otherwise, with the same diagnostic shape as §5.3.

6. The Scheme concept ​

A scheme is the handle the library parses through and identifies by pURL type. The struct is both the version value and the scheme handle: SemVer is the version type and carries the static parse, purlType, and range helpers — there is no separate scheme singleton. (Background: the design.)

6.1 Required surface — isVersionScheme!S ​

d
template isVersionScheme(S)
{
    enum isVersionScheme =
        is(S.Version) && isVersion!(S.Version) &&
        is(typeof(S.purlType) : string) && S.purlType.length > 0 &&
        is(typeof(S.parse("")) : ParseExpected!(S.Version));
}

A conforming scheme provides:

d
alias Version = S;                          // usually the struct itself
alias Range   = Ranges!S;
enum string purlType = "semver";            // non-empty pURL type string
static ParseExpected!S parse(string s);     // exact-syntax parser (§7)

purlType is required to be non-empty. A scheme that has no published Package-URL type (the D-internal schemes — Dmd, Tiny, the CalVer schemes, …) still declares a synthetic, scheme-named purlType (e.g. "dmd") to satisfy the concept. Such synthetic identifiers are not published pURL types: the purl→scheme registry (§10) maps only real ecosystem types, so an incoming pkg:dmd/... is never resolved to an internal scheme. PRESETS.md marks these schemes "purl type: none" in that sense.

6.2 Optional scheme capabilities ​

CapabilityDetection ruleBehavioural impact
supportsNativeRange!S.parseNativeRange("") → ParseExpected!(Ranges!(S.Version))parse the ecosystem's native range grammar
supportsLooseParse!S.parseLoose("") → ParseExpected!(S.Version)accept compatibility forms (v1.2, 1)
d
enum supportsNativeRange(S) =
    is(typeof(S.parseNativeRange("")) : ParseExpected!(Ranges!(S.Version)));
enum supportsLooseParse(S) =
    is(typeof(S.parseLoose("")) : ParseExpected!(S.Version));

A scheme that only parses exact versions is still a valid isVersionScheme; the VERS and pURL layers static if on these capabilities. Each shipped scheme module ends with a compile-time conformance assertion:

d
static assert(isVersion!SemVer && isVersionScheme!SemVer);

6.3 Cross-scheme incomparability ​

Cross-scheme comparison does not compile. There is no opCmp(SemVer, PypiVersion) because SemVer and PypiVersion are distinct nominal types — the type system rules out a cross-scheme order at compile time. A caller that must hold versions of mixed schemes uses AnyVersion and the partial compareAny, which returns null across schemes.

7. Parsing ​

Parsing is non-throwing and Expected-based. The error vocabulary is generic and lives in sparkles.base.text.errors (reused by every Sparkles text parser, not just versions); ParseMode is a versions enum in sparkles.versions.parsing.

d
// sparkles.base.text.errors (generic, @nogc)
enum ParseErrorCode
{
    emptyInput, unexpectedCharacter, unexpectedEnd, leadingZero,
    numericOverflow, invalidIdentifier, widthMismatch, /* … */
}

struct ParseError
{
    ParseErrorCode code;  /// what went wrong
    size_t offset;        /// byte offset (within the parsed input) of the failure
}

alias ParseExpected(T) = Expected!(T, ParseError, NoGcHook);

// sparkles.versions.parsing
enum ParseMode { strict, loose }

ParseExpected!T carries either a parsed T or a structured ParseError. Branch on result.hasValue / result.error:

d
auto r = SemVer.parse("1.2.x");
if (r.hasValue)
    use(r.value);
else
    report(r.error.code, r.error.offset);  // unexpectedCharacter @ 4

The parsing surface across the three concepts:

FunctionConceptRequired?Behaviour
S.parse(s)isVersionScheme!Srequiredexact canonical syntax → ParseExpected!(S.Version)
S.parseLoose(s)supportsLooseParse!Soptionaladditionally accept v-prefix, missing trailing components (zero-fill)
S.parseNativeRange(s)supportsNativeRange!Soptionalthe ecosystem's native range grammar → ParseExpected!(S.Range)

ParseMode is the strict/loose selector for schemes that route both behaviours through one entry point; parseLoose is the discoverable, capability-gated form. Each scheme's exact grammar and native range grammar are documented in the scheme catalogue.

8. Shipped schemes ​

The library ships a set of preset schemes that span the capability space — from SemVer (every optional capability) to Generic (none). The full list, the complete capability matrix, and the per-scheme detail (real-world examples, ordering rules, native-range grammar, edge cases, provenance) live in the scheme catalogue — adding a preset touches only that document.

Every scheme provides the required isVersion!T surface (opCmp + toString). An illustrative slice shows how the optional capabilities vary across schemes:

SchemehasOrderKeysupportsPrereleasehasComponentshasBuildMetadata
SemVer✅✅✅✅
CalVerYYMM✅—✅—
PypiVersion—✅✅—
Generic————
  • SemVer packs into an orderKey and carries the full SemVer triple (so it also gets caret/tilde via hasSemVerComponents).
  • CalVerYYMM is numeric-only — no prereleases, and its ["year","month","day"] list gives hasComponents but not the caret/tilde-enabling hasSemVerComponents.
  • PypiVersion is structural: its ordering does not pack into an integer, so it omits orderKey, yet it still has prereleases and components.
  • Generic is the opaque baseline with no optional capabilities — it exercises every generic algorithm's fallback path.

9. VERS interop ​

VERS is a URI scheme for version-range expressions: vers:<scheme>/<constraint>|<constraint>|…. The sparkles.versions.vers module parses and emits the URI surface; per-scheme constraint translation lives on each scheme struct.

d
struct VersUri
{
    string scheme;        // "npm", "pypi", "deb", "semver", …
    string[] constraints; // pre-split on '|', not yet typed
}

ParseExpected!VersUri parseVersUri(string s);
void formatVersUri(W)(ref W w, in VersUri v);   // textual: constraints in stored order

parseVersUri handles only the URI surface: prefix check, scheme extraction, |-splitting, and ASCII/lowercase normalisation. formatVersUri renders a VersUri's constraints in stored order — it is scheme-agnostic and so cannot version-sort. Translating a constraint segment to a typed Ranges!V and back is done by generic functions parameterised by the scheme:

d
ParseExpected!(Ranges!S) fromVersConstraint(S)(string segment);  // segment → Range
void toVersConstraint(S, W)(ref W w, in Ranges!S r);             // Range → segments

// vers-spec CANONICAL emit: version-ordered (a `Ranges!S` is sorted).
void   formatVersAs(Scheme, W)(ref W w, in Ranges!(Scheme.Version) r);
string toVersUriStringAs(Scheme)(in Ranges!(Scheme.Version) r);

formatVersAs!Scheme is the canonical emitter: because a Ranges!S is a sorted, disjoint interval list, it emits constraints in version order (e.g. 10.0.0|9.0.0 canonicalises to 9.0.0|10.0.0), matching the vers-spec — unlike the textual formatVersUri.

The scheme registry (sparkles.versions.schemes.registry) is built at compile time: schemeForPurlType!purlType resolves a published pURL type to its scheme struct (the synthetic internal purlTypes of §6.1 are excluded). It drives two dispatch forms:

d
/// Static dispatch when the caller knows the scheme at compile time.
template parseVersAs(SchemeStruct)
    if (isVersionScheme!SchemeStruct)
{
    ParseExpected!(SchemeStruct.Range) parseVersAs(string versUri);
}

/// Runtime dispatch on the URI's `scheme` field → AnyRange (§11).
ParseExpected!AnyRange parseVersAny(string versUri);

Round-trip law. For every scheme S and every native range expression e that S.parseNativeRange(e) accepts, parseNativeRange(e) → toVersConstraint → fromVersConstraint yields a Ranges!(S.Version) equal to the original. The test suite asserts this per scheme.

10. pURL interop ​

pURL (Package URL) names a package across ecosystems: pkg:<type>/<namespace>/<name>@<version>?<qualifiers>#<subpath>. The sparkles.versions.purl module consumes purls — it parses, it does not generate them:

d
struct PackageUrl
{
    string type;                 // "pypi", "npm", "deb", "maven", …
    string namespace;            // optional, may contain '/'
    string name;
    string ver;                  // raw version string; not yet parsed
    string[string] qualifiers;
    string subpath;
}

ParseExpected!PackageUrl parsePurl(string s);

The purl type does not always equal the VERS scheme verbatim (e.g. pkg:packagist/… maps to the composer scheme), so dispatch goes through a mapping table rather than identity. Two dispatch forms mirror §9:

d
/// Compile-time: resolve a purl type to its scheme struct, or fail to
/// compile when no built-in scheme matches.
template schemeForPurlType(string purlType) { /* … */ }

/// Runtime: parse a purl and return an AnyVersion (§11).
ParseExpected!AnyVersion parsePurlVersion(string purlUri);

parsePurlVersion parses the URI, resolves type → scheme through the mapping table, hands the raw ver string to that scheme's parse, and wraps the result in AnyVersion.

11. AnyVersion / AnyRange ​

Callers that handle versions of statically-unknown scheme (purl-driven workflows, SBOM ingestion, vulnerability matching) use the sum types in sparkles.versions.any:

d
import std.sumtype : SumType;

alias AnyVersion = SumType!(SemVer, Dmd, DmdCompact, Tiny,
    CalVerYYMM, CalVerYYYYMMDD, VimVer,
    PypiVersion, MavenVersion, DebianVersion, Generic);

alias AnyRange = SumType!(Ranges!SemVer, Ranges!Dmd, /* … one per scheme */);

Because there is no universal order across schemes (§6.3), cross-scheme comparison is partial:

d
/// Three-way compare wrapped in a Nullable!int.
/// Same scheme        → Nullable(a.opCmp(b)).
/// Differing schemes  → null (no cross-scheme order exists).
Nullable!int compareAny(in AnyVersion a, in AnyVersion b);

compareAny returning null is the defined contract, not a failure mode. (Background: no cross-scheme order.)

12. Public API surface ​

A consumer who needs a single ecosystem imports just that scheme:

d
import sparkles.versions.schemes.semver : SemVer;
import sparkles.versions.parsing : ParseMode;
import sparkles.base.text.errors : ParseError, ParseErrorCode;

A polyglot consumer (purl/VERS-driven) imports the package module, which re-exports the concepts, the parse types, Ranges, the sum types, the interop entry points, and every shipped scheme:

d
import sparkles.versions;   // SemVer, PypiVersion, …, AnyVersion,
                            // Ranges, parseVersUri, parsePurl, compareAny, …

A scheme author — writing a new ecosystem struct in their own code — imports the concepts and the generic range type, then asserts conformance:

d
import sparkles.versions.traits : isVersion, isVersionScheme,
    hasOrderKey, supportsPrerelease, hasComponents, hasSemVerComponents,
    hasBuildMetadata;
import sparkles.versions.ranges : Ranges;
import sparkles.base.text.errors : ParseExpected, ParseError, ParseErrorCode;

struct MyScheme { /* … */ }
static assert(isVersion!MyScheme && isVersionScheme!MyScheme);

Any struct conforming to isVersion!T participates in every generic algorithm; conforming additionally to isVersionScheme!S plugs into the VERS and pURL layers. Static use needs no registration — the registry (§9) discovers built-in schemes at compile time, and a user-defined scheme is used directly through its own type.


→ PLAN.md — delivery milestones and workflow orchestration → Explanation guides — design history, prior-art, decisions → Scheme catalogue — per-scheme examples, provenance, how-to-add-a-scheme