Skip to content

Semver Checker — Swift 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 Swift 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. Language: Swift (5.9, Foundation only). 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 Foundation

/// A parsed version: major.minor.patch plus dot-separated prerelease identifiers.
struct SemVer: CustomStringConvertible {
    let major: Int, minor: Int, patch: Int
    var pre: [String] = []

    static let rx = try! NSRegularExpression(pattern: "^(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. Nil on invalid.
    static func parse(_ input: String) -> SemVer? {
        var t = input.trimmingCharacters(in: .whitespaces)
        if let f = t.first, f == "v" || f == "V" { t.removeFirst() }   // tolerate the common "v1.2.3" form
        let range = NSRange(t.startIndex..., in: t)
        guard let m = rx.firstMatch(in: t, range: range) else { return nil }
        func g(_ i: Int) -> String? {                                  // capture group -> substring
            guard let r = Range(m.range(at: i), in: t) else { return nil }
            return String(t[r]) }
        return SemVer(major: Int(g(1)!)!, minor: Int(g(2)!)!, patch: Int(g(3)!)!,
                      pre: g(4).map { $0.split(separator: ".", omittingEmptySubsequences: false).map(String.init) } ?? [])
    }

    var description: String { "\(major).\(minor).\(patch)" + (pre.isEmpty ? "" : "-" + pre.joined(separator: ".")) }
}

/// Partial range version: nil field = absent/wildcard (fields cascade down).
struct Partial { let major: Int?, minor: Int?, patch: Int? }
/// One primitive test: op in {">=", ">", "<", "<=", "="} against a full version.
struct Test { let op: String; let v: Partial }

func digitsOnly(_ s: String) -> Bool { !s.isEmpty && s.allSatisfy { $0.isASCII && $0.isNumber } }
func cmp(_ a: Int, _ b: Int) -> Int { (a > b) - (a < b) }

func identCmp(_ x: String, _ y: String) -> Int {
    let (xd, yd) = (digitsOnly(x), digitsOnly(y))
    if xd && yd { return cmp(Int(x)!, Int(y)!) }      // numeric ids compare numerically
    if xd { return -1 }                                // numeric ids rank below alphanumeric
    if yd { return 1 }
    return x < y ? -1 : x > y ? 1 : 0
}

/// Compare by precedence: -1 a<b, 0 equal, 1 a>b. Build metadata is ignored.
func compare(_ a: SemVer, _ b: SemVer) -> Int {
    var c = cmp(a.major, b.major); if c != 0 { return c }
    c = cmp(a.minor, b.minor); if c != 0 { return c }
    c = cmp(a.patch, b.patch); if c != 0 { return c }
    if a.pre.isEmpty || b.pre.isEmpty { return cmp(b.pre.count, a.pre.count) }  // release > prerelease
    for i in 0..<min(a.pre.count, b.pre.count) { c = identCmp(a.pre[i], b.pre[i]); if c != 0 { return c } }
    return cmp(a.pre.count, b.pre.count)               // equal shared ids: larger set wins
}

/// Parse a range partial: "1", "1.2", "1.2.3", "1.x", "*" — wildcards cascade down.
func parsePartial(_ s: String) -> Partial? {
    var t = s; if let f = t.first, f == "v" || f == "V" { t.removeFirst() }
    if t.isEmpty || ["*", "x", "X"].contains(t) { return Partial(major: nil, minor: nil, patch: nil) }
    let f = t.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
    if f.count > 3 { return nil }
    var v: [Int?] = [nil, nil, nil]
    for (i, part) in f.enumerated() {
        if ["*", "x", "X"].contains(part) { continue } // wildcard: trailing fields drop
        guard digitsOnly(part) else { return nil }
        v[i] = Int(part)! }
    if v[0] == nil { return Partial(major: nil, minor: nil, patch: nil) }
    if v[1] == nil { return Partial(major: v[0], minor: nil, patch: nil) }
    return Partial(major: v[0], minor: v[1], patch: v[2])
}

/// Desugar one comparator (op + partial) into primitive >=/< tests.
func desugar(_ op: String, _ t: Partial) -> [Test] {
    guard let M = t.major else { return [] }           // wildcard matches everything
    let m = t.minor ?? 0, p = t.patch ?? 0
    func T(_ a: Int, _ b: Int, _ c: Int) -> Partial { Partial(major: a, minor: b, patch: c) }
    switch op {
    case "^":                                          // never break the left-most non-zero part
        let hi: Test
        if M > 0 || t.minor == nil { hi = Test(op: "<", v: T(M + 1, 0, 0)) }
        else if t.minor! > 0 || t.patch == nil { hi = Test(op: "<", v: T(0, t.minor! + 1, 0)) }
        else { hi = Test(op: "<", v: T(0, 0, t.patch! + 1)) }
        return [Test(op: ">=", v: T(M, m, p)), hi]
    case "~":
        return [Test(op: ">=", v: T(M, m, p)),
                Test(op: "<", v: t.minor == nil ? T(M + 1, 0, 0) : T(M, t.minor! + 1, 0))]
    case "bare", "=":                                  // bare "1.2" acts as >=1.2.0 <1.3.0
        if t.minor == nil { return [Test(op: ">=", v: T(M, 0, 0)), Test(op: "<", v: T(M + 1, 0, 0))] }
        if t.patch == nil { return [Test(op: ">=", v: T(M, t.minor!, 0)), Test(op: "<", v: T(M, t.minor! + 1, 0))] }
        return [Test(op: "=", v: t)]
    case ">=": return [Test(op: ">=", v: T(M, m, p))]
    case ">":                                          // >1 desugars to >=2.0.0 (partial floors up)
        if t.minor == nil { return [Test(op: ">=", v: T(M + 1, 0, 0))] }
        if t.patch == nil { return [Test(op: ">=", v: T(M, t.minor! + 1, 0))] }
        return [Test(op: ">", v: t)]
    case "<=":
        if t.minor == nil { return [Test(op: "<", v: T(M + 1, 0, 0))] }
        if t.patch == nil { return [Test(op: "<", v: T(M, t.minor! + 1, 0))] }
        return [Test(op: "<=", v: t)]
    default:                                           // "<"
        if t.minor == nil { return [Test(op: "<", v: T(M, 0, 0))] }
        if t.patch == nil { return [Test(op: "<", v: T(M, t.minor!, 0))] }
        return [Test(op: "<", v: t)]
    }
}

func check(_ t: Test, _ v: SemVer) -> Bool {
    let c = compare(v, SemVer(major: t.v.major ?? 0, minor: t.v.minor ?? 0, patch: t.v.patch ?? 0))
    switch t.op { case ">": return c > 0; case "<": return c < 0; case "<=": return c <= 0; case "=": return c == 0; default: return c >= 0 }
}

/// Evaluate one AND-clause (already split from "||"): hyphen ranges, ^ ~ comparators, partials.
func clauseMatches(_ v: SemVer, _ clause: String) -> Bool {
    let toks = clause.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
    var tests: [Test]
    if toks.count == 3 && toks[1] == "-" {             // hyphen range: >=lo <=hi, partials floor/ceiling
        guard let lo = parsePartial(toks[0]), let hi = parsePartial(toks[2]) else { return false }
        tests = desugar(">=", lo) + desugar("<=", hi)
    } else {
        tests = []
        for tok in toks {
            var op = "bare", r = tok
            if let f = r.first, f == "^" || f == "~" { op = String(f); r.removeFirst() }
            else if r.hasPrefix(">=") || r.hasPrefix("<=") { op = String(r.prefix(2)); r.removeFirst(2) }
            else if let f = r.first, f == ">" || f == "<" || f == "=" { op = String(f); r.removeFirst() }
            guard let part = parsePartial(r) else { return false }  // invalid comparator -> unsatisfiable
            tests += desugar(op, part) }
    }
    return tests.allSatisfy { check($0, v) }           // no tests ("*" clause) -> true
}

/// Does `version` satisfy the npm-style `range`? An invalid version yields false.
func satisfies(_ version: String, _ range: String) -> Bool {
    guard let v = SemVer.parse(version) else { return false }
    // NB: splitting on "||" — Swift has no String.split(separator: String) pre-5.9's components.
    return range.components(separatedBy: "||").contains { clauseMatches(v, $0) }
}

/// Bump by kind; the prerelease bump increments a trailing numeric id (or appends -0 / .1). Invalid input passes through.
func bump(_ input: String, _ kind: String) -> String {
    guard let s = SemVer.parse(input) else { return input }
    switch kind {
    case "major": return "\(s.major + 1).0.0"
    case "minor": return "\(s.major).\(s.minor + 1).0"
    case "patch": return "\(s.major).\(s.minor).\(s.patch + 1)"
    default:
        if s.pre.isEmpty { return "\(s.major).\(s.minor).\(s.patch + 1)-0" }
        let last = s.pre[s.pre.count - 1]
        let head = s.pre.count > 1 ? s.pre.dropLast().joined(separator: ".") + "." : ""
        return "\(s.major).\(s.minor).\(s.patch)-\(head)\(digitsOnly(last) ? String(Int(last)! + 1) : last + ".1")"
    }
}

let a = SemVer.parse("1.2.3-alpha.1+build.5")!
print("parse   1.2.3-alpha.1+build.5 -> \(a)")
let 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 i in 0..<(chain.count - 1) {
    print("compare \(chain[i]) \(compare(SemVer.parse(chain[i])!, SemVer.parse(chain[i + 1])!) < 0 ? "<" : "!") \(chain[i + 1])") }
let 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", "*")]
for (v, r) in vr { print(String(format: "satisfies %-7s %-26s -> %@", v, r, satisfies(v, r) ? "true" : "false")) }
print("bump    1.2.3 minor         -> \(bump("1.2.3", "minor"))")
print("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 →