Skip to content

Semver Checker — Ruby 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 Ruby 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: Ruby (3.1+, stdlib 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).

module SemVer
  IDENT = /(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)/
  SEMVER_RE = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:#{IDENT})(?:\.#{IDENT})*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?\z/
  NUMERIC = /\A\d+\z/

  module_function

  # Parse strict "M.m.p[-pre][+build]"; a leading v/V is tolerated. nil on invalid.
  def parse(input)
    t = input.strip.sub(/\A[vV]/, '')
    m = SEMVER_RE.match(t) or return nil
    { major: m[1].to_i, minor: m[2].to_i, patch: m[3].to_i,
      pre: m[4] ? m[4].split('.') : [] }
  end

  # Compare by precedence: -1 a<b, 0 equal, 1 a>b. Build metadata is ignored.
  def compare(a, b)
    %i[major minor patch].each { |f| return (a[f] <=> b[f]) unless (r = a[f] <=> b[f]).zero? }
    return b[:pre].length <=> a[:pre].length if a[:pre].empty? || b[:pre].empty?  # release > prerelease
    a[:pre].zip(b[:pre]).each do |x, y|
      r = ident_cmp(x, y)
      return r unless r.zero?
    end
    a[:pre].length <=> b[:pre].length           # equal shared ids: larger set wins
  end

  def ident_cmp(x, y)                            # numeric ids compare numerically and
    xn, yn = NUMERIC.match?(x), NUMERIC.match?(y) # rank below alphanumeric ones
    return x.to_i <=> y.to_i if xn && yn
    return -1 if xn
    return 1 if yn
    x <=> y
  end

  # Parse a range partial: "1", "1.2", "1.2.3", "1.x", "*" — wildcards cascade down.
  def parse_partial(s)
    t = s.sub(/\A[vV]/, '')
    return [nil, nil, nil] if t.empty? || ['*', 'x', 'X'].include?(t)
    f = t.split('.', -1)
    return nil if f.length > 3
    v = f.each_with_index.map do |part, _i|
      next nil if ['*', 'x', 'X'].include?(part) # wildcard: trailing fields drop
      return nil unless NUMERIC.match?(part)
      part.to_i
    end
    v[0].nil? ? [nil, nil, nil] : [v[0], v[1], v[2]]
  end

  # Desugar one comparator (op + partial) into primitive >=/< tests.
  def desugar(op, t)
    mjr = t[0] or return []                      # wildcard matches everything
    m, p = t[1] || 0, t[2] || 0
    case op
    when '^'                                     # never break the left-most non-zero part
      hi = if mjr > 0 || t[1].nil? then ['<', mjr + 1, 0, 0]
           elsif t[1] > 0 || t[2].nil? then ['<', 0, t[1] + 1, 0]
           else ['<', 0, 0, t[2] + 1] end
      [['>=', mjr, m, p], hi]
    when '~' then [['>=', mjr, m, p], t[1].nil? ? ['<', mjr + 1, 0, 0] : ['<', mjr, t[1] + 1, 0]]
    when 'bare', '='                             # bare "1.2" acts as >=1.2.0 <1.3.0
      if t[1].nil? then [['>=', mjr, 0, 0], ['<', mjr + 1, 0, 0]]
      elsif t[2].nil? then [['>=', mjr, t[1], 0], ['<', mjr, t[1] + 1, 0]]
      else [['=', mjr, t[1], t[2]]] end
    when '>=' then [['>=', mjr, m, p]]
    when '>'                                     # >1 desugars to >=2.0.0 (partial floors up)
      if t[1].nil? then [['>=', mjr + 1, 0, 0]]
      elsif t[2].nil? then [['>=', mjr, t[1] + 1, 0]]
      else [['>', mjr, t[1], t[2]]] end
    when '<='
      if t[1].nil? then [['<', mjr + 1, 0, 0]]
      elsif t[2].nil? then [['<', mjr, t[1] + 1, 0]]
      else [['<=', mjr, t[1], t[2]]] end
    else                                         # '<'
      if t[1].nil? then [['<', mjr, 0, 0]]
      elsif t[2].nil? then [['<', mjr, t[1], 0]]
      else [['<', mjr, t[1], t[2]]] end
    end
  end

  def check(t, v)
    c = compare(v, { major: t[1], minor: t[2], patch: t[3], pre: [] })
    case t[0]
    when '>' then c > 0
    when '<' then c < 0
    when '<=' then c <= 0
    when '=' then c.zero?
    else c >= 0                                  # '>='
    end
  end

  # Evaluate one AND-clause (already split from ||): hyphen ranges, ^ ~ comparators, partials.
  def clause_matches(v, clause)
    toks = clause.split
    tests = if toks.length == 3 && toks[1] == '-' # hyphen range: >=lo <=hi, partials floor/ceiling
              lo, hi = parse_partial(toks[0]), parse_partial(toks[2])
              return false if lo.nil? || hi.nil?
              desugar('>=', lo) + desugar('<=', hi)
            else
              toks.flat_map do |tok|
                op = 'bare'
                op, tok = tok[0], tok[1..] if ['^', '~'].include?(tok[0])
                op, tok = tok[0, 2], tok[2..] if ['>=', '<='].include?(tok[0, 2])
                op, tok = tok[0], tok[1..] if ['>', '<', '='].include?(tok[0]) && op == 'bare'
                part = parse_partial(tok) or return false # invalid comparator -> unsatisfiable
                desugar(op, part)
              end
            end
    tests.all? { |t| check(t, v) }               # no tests ("*" clause) -> true
  end

  # Does `version` satisfy the npm-style `range`? An invalid version yields false.
  def satisfies(version, range)
    v = parse(version) or return false
    range.split('||').any? { |clause| clause_matches(v, clause) }
  end

  # Bump by kind; the prerelease bump increments a trailing numeric id (or appends
  # -0 / .1). Invalid input passes through unchanged.
  def bump(input, kind)
    s = parse(input) or return input
    case kind
    when 'major' then "#{s[:major] + 1}.0.0"
    when 'minor' then "#{s[:major]}.#{s[:minor] + 1}.0"
    when 'patch' then "#{s[:major]}.#{s[:minor]}.#{s[:patch] + 1}"
    else
      return "#{s[:major]}.#{s[:minor]}.#{s[:patch] + 1}-0" if s[:pre].empty?
      last = s[:pre].last
      head = s[:pre].length > 1 ? "#{s[:pre][0..-2].join('.')}." : ''
      "#{s[:major]}.#{s[:minor]}.#{s[:patch]}-#{head}#{NUMERIC.match?(last) ? last.to_i + 1 : "#{last}.1"}"
    end
  end
end

if $PROGRAM_NAME == __FILE__
  a = SemVer.parse('1.2.3-alpha.1+build.5')
  puts "parse   1.2.3-alpha.1+build.5 -> #{a[:major]}.#{a[:minor]}.#{a[:patch]}-#{a[:pre].join('.')}"
  chain = %w[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]
  chain.each_cons(2) { |x, y| puts "compare #{x} #{SemVer.compare(SemVer.parse(x), SemVer.parse(y)).negative? ? '<' : '!'} #{y}" }
  [['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', '*']].each do |v, r|
    puts format('satisfies %-7s %-26s -> %s', v, r, SemVer.satisfies(v, r))
  end
  puts "bump    1.2.3 minor         -> #{SemVer.bump('1.2.3', 'minor')}"
  puts "bump    1.2.3-beta.2 pre    -> #{SemVer.bump('1.2.3-beta.2', 'prerelease')}"
end

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 →