Skip to content

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 →