Semver Checker — Kotlin 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 Kotlin 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. Kotlin (1.9+) port of src/lib/semver.ts — same logic as this dir's python.py; build metadata is parsed but ignored (it never affects precedence).
import kotlin.math.sign
/** A parsed version: major.minor.patch plus dot-separated prerelease identifiers. */
data class SemVer(val major: Long, val minor: Long, val patch: Long, val pre: List<String>) {
companion object {
private val RX = Regex("""^(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-]+)*))?$""")
/** Parse strict "M.m.p[-pre][+build]"; a leading v/V is tolerated. Null on invalid. */
fun parse(input: String): SemVer? {
val t = input.trim().trimStart('v', 'V') // tolerate the common "v1.2.3" form
val m = RX.matchEntire(t) ?: return null
return SemVer(m.groupValues[1].toLong(), m.groupValues[2].toLong(), m.groupValues[3].toLong(),
if (m.groupValues[4].isEmpty()) emptyList() else m.groupValues[4].split('.'))
}
}
}
/** Partial range version: null field = absent/wildcard (fields cascade down). */
data class Triple(val major: Long?, val minor: Long?, val patch: Long?)
/** One primitive test: op in {">=", ">", "<", "<=", "="} against a full version. */
data class Test(val op: String, val v: Triple)
private fun digits(s: String) = s.isNotEmpty() && s.all { it in '0'..'9' }
private fun cmp(a: Long, b: Long) = (a - b).sign.toInt()
private fun identCmp(x: String, y: String): Int {
val (xd, yd) = digits(x) to digits(y)
return when {
xd && yd -> cmp(x.toLong(), y.toLong()) // numeric ids compare numerically
xd -> -1 // numeric ids rank below alphanumeric
yd -> 1
else -> x.compareTo(y)
}
}
/** Compare by precedence: -1 a<b, 0 equal, 1 a>b. Build metadata is ignored. */
fun compare(a: SemVer, b: SemVer): Int {
cmp(a.major, b.major).takeIf { it != 0 }?.let { return it }
cmp(a.minor, b.minor).takeIf { it != 0 }?.let { return it }
cmp(a.patch, b.patch).takeIf { it != 0 }?.let { return it }
if (a.pre.isEmpty() || b.pre.isEmpty()) return cmp(b.pre.size.toLong(), a.pre.size.toLong()) // release > prerelease
for (i in 0 until minOf(a.pre.size, b.pre.size)) identCmp(a.pre[i], b.pre[i]).takeIf { it != 0 }?.let { return it }
return cmp(a.pre.size.toLong(), b.pre.size.toLong()) // equal shared ids: larger set wins
}
/** Parse a range partial: "1", "1.2", "1.2.3", "1.x", "*" — wildcards cascade down. */
private fun parsePartial(s: String): Triple? {
val t = s.trimStart('v', 'V')
if (t.isEmpty() || t in listOf("*", "x", "X")) return Triple(null, null, null)
val f = t.split('.')
if (f.size > 3) return null
val v = arrayOfNulls<Long>(3)
f.forEachIndexed { i, part ->
when {
part in listOf("*", "x", "X") -> {} // wildcard: trailing fields drop
digits(part) -> v[i] = part.toLong()
else -> return null
} }
return if (v[0] == null) Triple(null, null, null)
else if (v[1] == null) Triple(v[0], null, null)
else Triple(v[0], v[1], v[2])
}
/** Desugar one comparator (op + partial) into primitive >=/< tests. */
private fun desugar(op: String, t: Triple): List<Test> {
val M = t.major ?: return emptyList() // wildcard matches everything
val m = t.minor ?: 0L; val p = t.patch ?: 0L
return when (op) {
"^" -> { // never break the left-most non-zero part
val hi = when {
M > 0 || t.minor == null -> Test("<", Triple(M + 1, 0, 0))
t.minor!! > 0 || t.patch == null -> Test("<", Triple(0, t.minor!! + 1, 0))
else -> Test("<", Triple(0, 0, t.patch!! + 1)) }
listOf(Test(">=", Triple(M, m, p)), hi) }
"~" -> listOf(Test(">=", Triple(M, m, p)),
Test("<", if (t.minor == null) Triple(M + 1, 0, 0) else Triple(M, t.minor!! + 1, 0)))
"bare", "=" -> when { // bare "1.2" acts as >=1.2.0 <1.3.0
t.minor == null -> listOf(Test(">=", Triple(M, 0, 0)), Test("<", Triple(M + 1, 0, 0)))
t.patch == null -> listOf(Test(">=", Triple(M, t.minor!!, 0)), Test("<", Triple(M, t.minor!! + 1, 0)))
else -> listOf(Test("=", t)) }
">=" -> listOf(Test(">=", Triple(M, m, p)))
">" -> when {
t.minor == null -> listOf(Test(">=", Triple(M + 1, 0, 0))) // >1 -> >=2.0.0
t.patch == null -> listOf(Test(">=", Triple(M, t.minor!! + 1, 0)))
else -> listOf(Test(">", t)) }
"<=" -> when {
t.minor == null -> listOf(Test("<", Triple(M + 1, 0, 0)))
t.patch == null -> listOf(Test("<", Triple(M, t.minor!! + 1, 0)))
else -> listOf(Test("<=", t)) }
else -> when { // "<"
t.minor == null -> listOf(Test("<", Triple(M, 0, 0)))
t.patch == null -> listOf(Test("<", Triple(M, t.minor!!, 0)))
else -> listOf(Test("<", t)) } }
}
private fun check(t: Test, v: SemVer): Boolean {
val c = compare(v, SemVer(t.v.major!!, t.v.minor!!, t.v.patch!!, emptyList()))
return when (t.op) { ">" -> c > 0; "<" -> c < 0; "<=" -> c <= 0; "=" -> c == 0; else -> c >= 0 }
}
/** Evaluate one AND-clause (already split from ||): hyphen ranges, ^ ~ comparators, partials. */
private fun clauseMatches(v: SemVer, clause: String): Boolean {
val toks = clause.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
val tests = when {
toks.size == 3 && toks[1] == "-" -> { // hyphen range: >=lo <=hi, partials floor/ceiling
val (lo, hi) = parsePartial(toks[0]) to parsePartial(toks[2])
if (lo == null || hi == null) return false
desugar(">=", lo) + desugar("<=", hi) }
else -> toks.flatMap { tok ->
var op = "bare"; var r = tok
when {
r[0] == '^' || r[0] == '~' -> { op = r.take(1); r = r.drop(1) }
r.startsWith(">=") || r.startsWith("<=") -> { op = r.take(2); r = r.drop(2) }
r[0] == '>' || r[0] == '<' || r[0] == '=' -> { op = r.take(1); r = r.drop(1) } }
val part = parsePartial(r) ?: return false // invalid comparator -> unsatisfiable
desugar(op, part) } }
return tests.all { check(it, v) } // no tests ("*" clause) -> true
}
/** Does `version` satisfy the npm-style `range`? An invalid version yields false. */
fun satisfies(version: String, range: String): Boolean {
val v = SemVer.parse(version) ?: return false
return range.split("||").any { clauseMatches(v, it) }
}
/** Bump by kind; the prerelease bump increments a trailing numeric id (or appends -0 / .1). Invalid input passes through. */
fun bump(input: String, kind: String): String {
val s = SemVer.parse(input) ?: return input
return when (kind) {
"major" -> "${s.major + 1}.0.0"
"minor" -> "${s.major}.${s.minor + 1}.0"
"patch" -> "${s.major}.${s.minor}.${s.patch + 1}"
else -> {
val head = if (s.pre.isEmpty()) "${s.major}.${s.minor}.${s.patch + 1}-0"
else "${s.major}.${s.minor}.${s.patch}-" +
(if (s.pre.size > 1) s.pre.dropLast(1).joinToString(".") + "." else "") +
(if (digits(s.pre.last())) (s.pre.last().toLong() + 1).toString() else s.pre.last() + ".1")
head } }
}
fun main() {
val a = SemVer.parse("1.2.3-alpha.1+build.5")!!
println("parse 1.2.3-alpha.1+build.5 -> ${a.major}.${a.minor}.${a.patch}-${a.pre.joinToString(".")}")
val chain = listOf("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 (i in 0 until chain.size - 1)
println("compare ${chain[i]} ${if (compare(SemVer.parse(chain[i])!!, SemVer.parse(chain[i + 1])!!) < 0) '<' else '!'} ${chain[i + 1]}")
val vr = listOf("1.2.3" to "^1.2.0", "1.9.0" to "~1.2.0", "0.2.5" to "^0.2.3", "0.3.0" to "^0.2.3",
"3.1.0" to ">=1.0.0 <2.0.0 || >=3.0.0", "1.5.0" to "1.2.3 - 1.9.0", "1.2.3" to "*")
for ((v, r) in vr) println("satisfies %-7s %-26s -> %s".format(v, r, satisfies(v, r)))
println("bump 1.2.3 minor -> " + bump("1.2.3", "minor"))
println("bump 1.2.3-beta.2 pre -> " + 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 →