Skip to content

Semver Checker — C# source

Parse, compare, and validate Semantic Versioning 2.0.0 strings. Check which of two versions is greater (with full prerelease precedence), test whether a version satisfies an npm-style range (^, ~, comparators, hyphen, ||), and bump major/minor/patch/prerelease. Runs 100% client-side.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// semver — Semantic Versioning 2.0.0: strict parse, precedence compare (incl. prerelease ordering), npm-style ranges (^ ~ comparators partials * hyphen AND ||) and bumping. C# (.NET 7+) port of src/lib/semver.ts — same logic as this dir's python.py; build metadata is parsed but ignored (it never affects precedence).
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

public partial record SemVer(long Major, long Minor, long Patch, string[] Pre)
{
    public static readonly Regex Rx = new(@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$", RegexOptions.Compiled);

    /// Parse strict "M.m.p[-pre][+build]"; a leading v/V is tolerated. Null on invalid.
    public static SemVer? Parse(string input)
    {
        var t = input.Trim().TrimStart('v', 'V');   // tolerate the common "v1.2.3" form
        var m = Rx.Match(t);
        return m.Success ? new(long.Parse(m.Groups[1].Value), long.Parse(m.Groups[2].Value),
                              long.Parse(m.Groups[3].Value),
                              m.Groups[4].Value.Length > 0 ? m.Groups[4].Value.Split('.') : Array.Empty<string>())
                         : null;
    }
}

public static class SemVerKit
{
    record Triple(long Major = -1, long Minor = -1, long Patch = -1);   // -1 = absent/wildcard
    record Test(string Op, Triple V);                                    // Op: >= > < <= =

    static bool Digits(string s) => s.Length > 0 && s.All(char.IsDigit);
    static int Cmp(long a, long b) => (a > b) - (a < b);

    static int IdentCmp(string x, string y)
    {
        bool xd = Digits(x), yd = Digits(y);
        if (xd && yd) return Cmp(long.Parse(x), long.Parse(y));   // numeric ids compare numerically
        if (xd || yd) return xd ? -1 : 1;                          // numeric ids rank below alphanumeric
        return string.CompareOrdinal(x, y);
    }

    /// Compare by precedence: -1 a&lt;b, 0 equal, 1 a&gt;b. Build metadata is ignored.
    public static int Compare(SemVer a, SemVer b)
    {
        int c = Cmp(a.Major, b.Major); if (c != 0) return c;
        if ((c = Cmp(a.Minor, b.Minor)) != 0) return c;
        if ((c = Cmp(a.Patch, b.Patch)) != 0) return c;
        if (a.Pre.Length == 0 || b.Pre.Length == 0) return Cmp(b.Pre.Length, a.Pre.Length);  // release > prerelease
        for (int i = 0; i < a.Pre.Length && i < b.Pre.Length; i++)
            if ((c = IdentCmp(a.Pre[i], b.Pre[i])) != 0) return c;
        return Cmp(a.Pre.Length, b.Pre.Length);                    // equal shared ids: larger set wins
    }

    /// Parse a range partial: "1", "1.2", "1.2.3", "1.x", "*" — wildcards cascade down.
    static Triple? ParsePartial(string s)
    {
        s = s.TrimStart('v', 'V');
        if (s.Length == 0 || s is "*" or "x" or "X") return new Triple();
        var f = s.Split('.');
        if (f.Length > 3) return null;
        var v = new long[] { -1, -1, -1 };
        for (int i = 0; i < f.Length; i++)
        {
            if (f[i] is "*" or "x" or "X") continue;               // wildcard: trailing fields drop
            if (!Digits(f[i])) return null;
            v[i] = long.Parse(f[i]);
        }
        return v[0] < 0 ? new Triple() : v[1] < 0 ? new Triple(v[0]) : new Triple(v[0], v[1], v[2]);
    }

    /// Desugar one comparator (op + partial) into primitive &gt;=/&lt; tests.
    static List<Test> Desugar(string op, Triple t)
    {
        long M = t.Major, m = t.Minor < 0 ? 0 : t.Minor, p = t.Patch < 0 ? 0 : t.Patch;
        var T = (long a, long b, long c) => new Triple(a, b, c);
        var add = new List<Test>();
        if (M < 0) return add;                                     // wildcard matches everything
        switch (op)
        {
            case "^":                                              // never break the left-most non-zero part
                add.Add(new(">=", T(M, m, p)));
                if (M > 0 || t.Minor < 0) add.Add(new("<", T(M + 1, 0, 0)));
                else if (t.Minor > 0 || t.Patch < 0) add.Add(new("<", T(0, t.Minor + 1, 0)));
                else add.Add(new("<", T(0, 0, t.Patch + 1)));
                break;
            case "~":
                add.Add(new(">=", T(M, m, p)));
                add.Add(new("<", t.Minor < 0 ? T(M + 1, 0, 0) : T(M, t.Minor + 1, 0)));
                break;
            case "bare" or "=":                                    // bare "1.2" acts as >=1.2.0 <1.3.0
                if (t.Minor < 0) { add.Add(new(">=", T(M, 0, 0))); add.Add(new("<", T(M + 1, 0, 0))); }
                else if (t.Patch < 0) { add.Add(new(">=", T(M, t.Minor, 0))); add.Add(new("<", T(M, t.Minor + 1, 0))); }
                else add.Add(new("=", t));
                break;
            case ">=": add.Add(new(">=", T(M, m, p))); break;
            case ">":
                if (t.Minor < 0) add.Add(new(">=", T(M + 1, 0, 0)));          // >1 -> >=2.0.0
                else if (t.Patch < 0) add.Add(new(">=", T(M, t.Minor + 1, 0)));
                else add.Add(new(">", t));
                break;
            case "<=":
                if (t.Minor < 0) add.Add(new("<", T(M + 1, 0, 0)));
                else if (t.Patch < 0) add.Add(new("<", T(M, t.Minor + 1, 0)));
                else add.Add(new("<=", t));
                break;
            default:                                               // "<"
                if (t.Minor < 0) add.Add(new("<", T(M, 0, 0)));
                else if (t.Patch < 0) add.Add(new("<", T(M, t.Minor, 0)));
                else add.Add(new("<", t));
                break;
        }
        return add;
    }

    static bool Check(Test t, SemVer v)
    {
        int c = Compare(v, new(t.V.Major, t.V.Minor, t.V.Patch, Array.Empty<string>()));
        return t.Op switch { ">" => c > 0, "<" => c < 0, "<=" => c <= 0, "=" => c == 0, _ => c >= 0 };
    }

    /// Evaluate one AND-clause (already split from ||): hyphen ranges, ^ ~ comparators, partials.
    static bool ClauseMatches(SemVer v, string clause)
    {
        var toks = clause.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (toks.Length == 3 && toks[1] == "-")
        {                                                          // hyphen range: >=lo <=hi, partials floor/ceiling
            var (lo, hi) = (ParsePartial(toks[0]), ParsePartial(toks[2]));
            if (lo == null || hi == null) return false;
            return Desugar(">=", lo).Concat(Desugar("<=", hi)).All(t => Check(t, v));
        }
        var tests = new List<Test>();
        foreach (var tok in toks)
        {
            string op = "bare", r = tok;
            if (r[0] is '^' or '~') { op = r[..1]; r = r[1..]; }
            else if (r.StartsWith(">=") || r.StartsWith("<=")) { op = r[..2]; r = r[2..]; }
            else if (r[0] is '>' or '<' or '=') { op = r[..1]; r = r[1..]; }
            var part = ParsePartial(r);
            if (part == null) return false;                        // invalid comparator -> unsatisfiable
            tests.AddRange(Desugar(op, part));
        }
        return tests.All(t => Check(t, v));
    }

    /// Does `version` satisfy the npm-style `range`? An invalid version yields false.
    public static bool Satisfies(string version, string range) =>
        SemVer.Parse(version) is { } v && range.Split("||").Any(cl => ClauseMatches(v, cl));

    /// Bump by kind; the prerelease bump increments a trailing numeric id (or appends -0 / .1). Invalid input passes through.
    public static string Bump(string input, string kind)
    {
        var s = SemVer.Parse(input);
        if (s == null) return input;
        return kind switch
        {
            "major" => $"{s.Major + 1}.0.0",
            "minor" => $"{s.Major}.{s.Minor + 1}.0",
            "patch" => $"{s.Major}.{s.Minor}.{s.Patch + 1}",
            _ => s.Pre.Length == 0 ? $"{s.Major}.{s.Minor}.{s.Patch + 1}-0"
                 : $"{s.Major}.{s.Minor}.{s.Patch}-" + (s.Pre.Length > 1 ? string.Join(".", s.Pre[..^1]) + "." : "")
                   + (Digits(s.Pre[^1]) ? (long.Parse(s.Pre[^1]) + 1).ToString() : s.Pre[^1] + ".1")
        };
    }
}

public static class SemVerDemo
{
    public static void Main()
    {
        var a = SemVer.Parse("1.2.3-alpha.1+build.5")!;
        Console.WriteLine($"parse   1.2.3-alpha.1+build.5 -> {a.Major}.{a.Minor}.{a.Patch}-{string.Join(".", a.Pre)}");
        string[] chain = { "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0" };
        for (int i = 0; i + 1 < chain.Length; i++)
            Console.WriteLine($"compare {chain[i]} {SemVerKit.Compare(SemVer.Parse(chain[i])!, SemVer.Parse(chain[i + 1])!) switch { -1 => '<', _ => '!' }} {chain[i + 1]}");
        (string, string)[] vr = { ("1.2.3", "^1.2.0"), ("1.9.0", "~1.2.0"), ("0.2.5", "^0.2.3"), ("0.3.0", "^0.2.3"),
                                  ("3.1.0", ">=1.0.0 <2.0.0 || >=3.0.0"), ("1.5.0", "1.2.3 - 1.9.0"), ("1.2.3", "*") };
        foreach (var (v, r) in vr) Console.WriteLine($"satisfies {v,-7} {r,-26} -> {SemVerKit.Satisfies(v, r)}");
        Console.WriteLine($"bump    1.2.3 minor         -> {SemVerKit.Bump("1.2.3", "minor")}");
        Console.WriteLine($"bump    1.2.3-beta.2 pre    -> {SemVerKit.Bump("1.2.3-beta.2", "prerelease")}");
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →