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 →