Skip to content

Semver Checker — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package semver is the Go twin of CosmoDev's src/lib/semver.ts (dual source:
// the web lib is TypeScript, the CLI lib is Go — kept in lock-step). Pure +
// deterministic, never panics. The table-driven tests in semver_test.go share
// vectors with src/lib/semver.test.ts so the two implementations are held to
// the same contract.
//
// Implements Semantic Versioning 2.0.0 (semver.org): parsing, precedence
// comparison (including prerelease ordering), npm-style range satisfaction
// (^, ~, comparators, *, AND, ||, hyphen ranges), and version bumping. The
// algorithm mirrors the TS lib exactly.
package semver

import (
	"fmt"
	"regexp"
	"strconv"
	"strings"
)

// Semver is a parsed semantic version. Build metadata is carried but ignored
// for precedence. It mirrors the Semver interface in src/lib/semver.ts.
type Semver struct {
	Major      int
	Minor      int
	Patch      int
	Prerelease []string // e.g. ["alpha", "1"] or ["0", "3", "7"]
	Build      []string // build-metadata identifiers (ignored for precedence)
}

// Strict semver 2.0.0 core, composed exactly as in src/lib/semver.ts. A leading
// 'v'/'V' and surrounding whitespace are stripped before matching.
// Capturing groups: 1=major 2=minor 3=patch 4=prerelease 5=build.
const (
	ident    = `(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)`
	preGroup = `(` + ident + `(?:\.` + ident + `)*)`
	buildPat = `([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)`
	numPat   = `(0|[1-9]\d*)`
)

var (
	semverRE = regexp.MustCompile(`^` + numPat + `\.` + numPat + `\.` + numPat + `(?:-` + preGroup + `)?(?:\+` + buildPat + `)?$`)
	numRe    = regexp.MustCompile(`^[0-9]+$`)
	hyphenRe = regexp.MustCompile(`\s+-\s+`)
)

// ParseSemver parses a strict semver string (an optional leading v/V is
// tolerated). It returns nil for any non-matching input, mirroring the TS
// parseSemver's null return.
func ParseSemver(v string) *Semver {
	t := strings.TrimSpace(v)
	// Strip exactly one leading v or V (matches /^[vV]/ single replacement).
	if len(t) > 0 && (t[0] == 'v' || t[0] == 'V') {
		t = t[1:]
	}
	m := semverRE.FindStringSubmatch(t)
	if m == nil {
		return nil
	}
	major, _ := strconv.Atoi(m[1])
	minor, _ := strconv.Atoi(m[2])
	patch, _ := strconv.Atoi(m[3])
	var prerelease, build []string
	if m[4] != "" {
		prerelease = strings.Split(m[4], ".")
	}
	if m[5] != "" {
		build = strings.Split(m[5], ".")
	}
	return &Semver{Major: major, Minor: minor, Patch: patch, Prerelease: prerelease, Build: build}
}

// Format renders a Semver back to its canonical string form.
func Format(s Semver) string {
	out := fmt.Sprintf("%d.%d.%d", s.Major, s.Minor, s.Patch)
	if len(s.Prerelease) > 0 {
		out += "-" + strings.Join(s.Prerelease, ".")
	}
	if len(s.Build) > 0 {
		out += "+" + strings.Join(s.Build, ".")
	}
	return out
}

// cmpIdent compares two prerelease identifiers: numeric < non-numeric; numeric
// by value; alphanumeric lexicographically. Mirrors cmpIdent() in the TS lib.
func cmpIdent(x, y string) int {
	xn := numRe.MatchString(x)
	yn := numRe.MatchString(y)
	if xn && yn {
		a, _ := strconv.Atoi(x)
		b, _ := strconv.Atoi(y)
		switch {
		case a < b:
			return -1
		case a > b:
			return 1
		default:
			return 0
		}
	}
	if xn {
		return -1 // numeric always lower than alphanumeric
	}
	if yn {
		return 1
	}
	switch {
	case x < y:
		return -1
	case x > y:
		return 1
	default:
		return 0
	}
}

// cmpPrerelease compares two prerelease arrays per semver precedence: a release
// with no prerelease has HIGHER precedence than one with.
func cmpPrerelease(a, b []string) int {
	if len(a) == 0 && len(b) == 0 {
		return 0
	}
	if len(a) == 0 {
		return 1 // no prerelease > prerelease
	}
	if len(b) == 0 {
		return -1
	}
	n := len(a)
	if len(b) < n {
		n = len(b)
	}
	for i := 0; i < n; i++ {
		c := cmpIdent(a[i], b[i])
		if c != 0 {
			return c
		}
	}
	// All shared identifiers equal → a larger set of fields wins.
	switch {
	case len(a) < len(b):
		return -1
	case len(a) > len(b):
		return 1
	default:
		return 0
	}
}

// Compare compares two semvers by precedence, returning -1 if a < b, 0 if
// equal, 1 if a > b. Build metadata is ignored. Mirrors compare() in the TS lib.
func Compare(a, b Semver) int {
	if a.Major != b.Major {
		if a.Major < b.Major {
			return -1
		}
		return 1
	}
	if a.Minor != b.Minor {
		if a.Minor < b.Minor {
			return -1
		}
		return 1
	}
	if a.Patch != b.Patch {
		if a.Patch < b.Patch {
			return -1
		}
		return 1
	}
	c := cmpPrerelease(a.Prerelease, b.Prerelease)
	if c < 0 {
		return -1
	}
	if c > 0 {
		return 1
	}
	return 0
}

// ─── Range satisfaction ─────────────────────────────────────────────────────

// A partial version for ranges: missing minor/patch are wildcards (nil).
type rangeVer struct {
	major *int
	minor *int
	patch *int
}

// Comparator operators. "bare" means no explicit operator (= / bare version).
const (
	opGE   = ">="
	opGT   = ">"
	opLE   = "<="
	opLT   = "<"
	opEQ   = "="
	opBare = "bare"
)

type testT struct {
	op string
	v  Semver
}

func sem(major, minor, patch int) Semver { return Semver{Major: major, Minor: minor, Patch: patch} }
func ge(v Semver) testT                  { return testT{op: opGE, v: v} }
func gt(v Semver) testT                  { return testT{op: opGT, v: v} }
func lt(v Semver) testT                  { return testT{op: opLT, v: v} }
func le(v Semver) testT                  { return testT{op: opLE, v: v} }
func eq(v Semver) testT                  { return testT{op: opEQ, v: v} }

// parseRangeVer parses a (possibly partial) range version: "1", "1.2", "1.2.3",
// "1.x", "*". It mirrors parseRangeVer() in the TS lib: nil means wildcard (or
// absent), a non-nil *int is a concrete number. The second return is false when
// a component is invalid.
func parseRangeVer(s string) (rangeVer, bool) {
	t := strings.TrimSpace(s)
	if len(t) > 0 && (t[0] == 'v' || t[0] == 'V') {
		t = t[1:]
	}
	if t == "" || t == "*" || t == "x" || t == "X" {
		return rangeVer{}, true // all nil → wildcard
	}
	parts := strings.Split(t, ".")
	if len(parts) > 3 {
		return rangeVer{}, false
	}
	// part returns (value, valid). nil value (valid) means wildcard; !valid is invalid.
	part := func(p string) (*int, bool) {
		if p == "x" || p == "X" || p == "*" {
			return nil, true
		}
		if numRe.MatchString(p) {
			n, _ := strconv.Atoi(p)
			return &n, true
		}
		return nil, false
	}
	major, ok := part(parts[0])
	if !ok {
		return rangeVer{}, false
	}
	var minorPtr, patchPtr *int
	if len(parts) >= 2 {
		mp, ok2 := part(parts[1])
		if !ok2 {
			return rangeVer{}, false
		}
		minorPtr = mp
	}
	if len(parts) >= 3 {
		pp, ok3 := part(parts[2])
		if !ok3 {
			return rangeVer{}, false
		}
		patchPtr = pp
	}
	if major == nil {
		return rangeVer{}, true // wildcard major → all nil
	}
	if minorPtr == nil {
		return rangeVer{major: major}, true // minor absent/wildcard → major only
	}
	return rangeVer{major: major, minor: minorPtr, patch: patchPtr}, true
}

// rangeVerTests builds the test list for a plain comparator (>=, >, <=, <, =/bare).
func rangeVerTests(op string, rv rangeVer) []testT {
	if rv.major == nil {
		return nil // wildcard → matches anything
	}
	M := *rv.major
	switch op {
	case opEQ, opBare:
		if rv.minor == nil {
			return []testT{ge(sem(M, 0, 0)), lt(sem(M+1, 0, 0))}
		}
		if rv.patch == nil {
			return []testT{ge(sem(M, *rv.minor, 0)), lt(sem(M, *rv.minor+1, 0))}
		}
		return []testT{eq(sem(M, *rv.minor, *rv.patch))}
	case opGE:
		if rv.minor == nil {
			return []testT{ge(sem(M, 0, 0))}
		}
		if rv.patch == nil {
			return []testT{ge(sem(M, *rv.minor, 0))}
		}
		return []testT{ge(sem(M, *rv.minor, *rv.patch))}
	case opGT:
		if rv.minor == nil {
			return []testT{ge(sem(M + 1, 0, 0))}
		}
		if rv.patch == nil {
			return []testT{ge(sem(M, *rv.minor+1, 0))}
		}
		return []testT{gt(sem(M, *rv.minor, *rv.patch))}
	case opLE:
		if rv.minor == nil {
			return []testT{lt(sem(M + 1, 0, 0))}
		}
		if rv.patch == nil {
			return []testT{lt(sem(M, *rv.minor+1, 0))}
		}
		return []testT{le(sem(M, *rv.minor, *rv.patch))}
	case opLT:
		if rv.minor == nil {
			return []testT{lt(sem(M, 0, 0))}
		}
		if rv.patch == nil {
			return []testT{lt(sem(M, *rv.minor, 0))}
		}
		return []testT{lt(sem(M, *rv.minor, *rv.patch))}
	}
	return nil
}

// caretTests builds the tests for a caret (^) range: compatible-with, never
// breaking the left-most non-zero component.
func caretTests(rv rangeVer) []testT {
	if rv.major == nil {
		return nil
	}
	M := *rv.major
	minor0 := 0
	if rv.minor != nil {
		minor0 = *rv.minor
	}
	patch0 := 0
	if rv.patch != nil {
		patch0 = *rv.patch
	}
	lo := ge(sem(M, minor0, patch0))
	var hi testT
	switch {
	case rv.minor == nil:
		hi = lt(sem(M+1, 0, 0)) // ^1 → <2.0.0, ^0 → <1.0.0
	case rv.patch == nil:
		if M > 0 {
			hi = lt(sem(M+1, 0, 0))
		} else {
			hi = lt(sem(0, *rv.minor+1, 0)) // ^0.2 → <0.3.0
		}
	default:
		switch {
		case M > 0:
			hi = lt(sem(M+1, 0, 0))
		case *rv.minor > 0:
			hi = lt(sem(0, *rv.minor+1, 0))
		default:
			hi = lt(sem(0, 0, *rv.patch+1)) // ^0.0.3 → <0.0.4
		}
	}
	return []testT{lo, hi}
}

// tildeTests builds the tests for a tilde (~) range: patch-level changes only
// (or minor-level for partials).
func tildeTests(rv rangeVer) []testT {
	if rv.major == nil {
		return nil
	}
	M := *rv.major
	minor0 := 0
	if rv.minor != nil {
		minor0 = *rv.minor
	}
	patch0 := 0
	if rv.patch != nil {
		patch0 = *rv.patch
	}
	lo := ge(sem(M, minor0, patch0))
	var hi testT
	if rv.minor == nil {
		hi = lt(sem(M+1, 0, 0))
	} else {
		hi = lt(sem(M, *rv.minor+1, 0))
	}
	return []testT{lo, hi}
}

// parseComparator parses a single comparator token into a list of tests (all
// must hold). The bool is false when the token is unparseable.
func parseComparator(token string) ([]testT, bool) {
	t := strings.TrimSpace(token)
	if t == "" || t == "*" {
		return nil, true
	}
	if t[0] == '^' {
		rv, ok := parseRangeVer(t[1:])
		if !ok {
			return nil, false
		}
		return caretTests(rv), true
	}
	if t[0] == '~' {
		rv, ok := parseRangeVer(t[1:])
		if !ok {
			return nil, false
		}
		return tildeTests(rv), true
	}
	op := opBare
	rest := t
	switch {
	case strings.HasPrefix(t, ">="):
		op, rest = opGE, t[2:]
	case strings.HasPrefix(t, "<="):
		op, rest = opLE, t[2:]
	case strings.HasPrefix(t, ">"):
		op, rest = opGT, t[1:]
	case strings.HasPrefix(t, "<"):
		op, rest = opLT, t[1:]
	case strings.HasPrefix(t, "="):
		op, rest = opEQ, t[1:]
	}
	rv, ok := parseRangeVer(rest)
	if !ok {
		return nil, false
	}
	return rangeVerTests(op, rv), true
}

func check(tt testT, v Semver) bool {
	c := Compare(v, tt.v)
	switch tt.op {
	case opGT:
		return c > 0
	case opGE:
		return c >= 0
	case opLT:
		return c < 0
	case opLE:
		return c <= 0
	case opEQ:
		return c == 0
	}
	return false
}

// clauseMatches evaluates one AND-clause (already split from "||").
func clauseMatches(v Semver, clause string) bool {
	c := strings.TrimSpace(clause)
	if c == "" || c == "*" {
		return true
	}

	// Hyphen range: "1.2.3 - 2.3.4" → >=lower <=upper (partials apply).
	if hyphenRe.MatchString(c) {
		parts := hyphenRe.Split(c, -1)
		if len(parts) == 2 {
			lo, okLo := parseRangeVer(parts[0])
			hi, okHi := parseRangeVer(parts[1])
			if !okLo || !okHi {
				return false
			}
			tests := append([]testT{}, rangeVerTests(opGE, lo)...)
			tests = append(tests, rangeVerTests(opLE, hi)...)
			for _, tt := range tests {
				if !check(tt, v) {
					return false
				}
			}
			return true
		}
	}

	tokens := strings.Fields(c)
	var tests []testT
	for _, tok := range tokens {
		ts, ok := parseComparator(tok)
		if !ok {
			return false // invalid comparator → clause unsatisfiable
		}
		tests = append(tests, ts...)
	}
	if len(tests) == 0 {
		return true
	}
	for _, tt := range tests {
		if !check(tt, v) {
			return false
		}
	}
	return true
}

// Satisfies reports whether version satisfies the npm-style range. Supports ^,
// ~, comparators (>=, <=, >, <, =), *, partials (1.2, 1), hyphen ranges
// (1.2.3 - 2.3.4), space-separated AND, and || OR. An invalid version or wholly
// unparseable range yields false; * / empty matches all.
func Satisfies(version, rng string) bool {
	v := ParseSemver(version)
	if v == nil {
		return false
	}
	for _, clause := range strings.Split(rng, "||") {
		if clauseMatches(*v, clause) {
			return true
		}
	}
	return false
}

// Bump bumps a version by kind. major/minor/patch drop any prerelease and
// produce a clean release; prerelease bumps the trailing numeric prerelease
// identifier (or appends "-0" / ".1" when there is none / a non-numeric tail).
// Invalid input is returned unchanged. An unknown kind is also returned
// unchanged (never panics).
func Bump(v, kind string) string {
	s := ParseSemver(v)
	if s == nil {
		return v
	}
	major, minor, patch := s.Major, s.Minor, s.Patch
	pre := s.Prerelease
	switch kind {
	case "major":
		return fmt.Sprintf("%d.0.0", major+1)
	case "minor":
		return fmt.Sprintf("%d.%d.0", major, minor+1)
	case "patch":
		return fmt.Sprintf("%d.%d.%d", major, minor, patch+1)
	case "prerelease":
		if len(pre) == 0 {
			return fmt.Sprintf("%d.%d.%d-0", major, minor, patch+1)
		}
		last := pre[len(pre)-1]
		if numRe.MatchString(last) {
			n, _ := strconv.Atoi(last)
			next := append([]string{}, pre...) // copy before mutating
			next[len(next)-1] = strconv.Itoa(n + 1)
			return fmt.Sprintf("%d.%d.%d-%s", major, minor, patch, strings.Join(next, "."))
		}
		tail := make([]string, 0, len(pre)+1)
		tail = append(tail, pre...)
		tail = append(tail, "1")
		return fmt.Sprintf("%d.%d.%d-%s", major, minor, patch, strings.Join(tail, "."))
	}
	return v
}

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 →