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<b, 0 equal, 1 a>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 >=/< 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 →