Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 111 additions & 12 deletions version.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,26 @@ type Semver struct {
Major int
Minor int
Patch int
// Pre holds the prerelease identifiers that follow the first hyphen,
// without the leading hyphen. It is empty for a plain release tag.
// "v1.2.3-rc1" yields "rc1"; "v1.2.3" yields "".
Pre string
}

// ParseSemver parses a version string like "v1.2.3" or "1.2.3" or "v1.2.3-dirty".
// It strips the "v" prefix and any suffix after a hyphen.
// ParseSemver parses a version string like "v1.2.3" or "1.2.3" or "v1.2.3-rc1".
// It strips the "v" prefix and any "+build" metadata, keeps the numeric
// major.minor.patch triple, and retains the prerelease suffix in Pre.
func ParseSemver(s string) (Semver, error) {
s = strings.TrimPrefix(s, "v")
// Strip anything after hyphen (e.g. "-dirty", "-rc1")
// Build metadata does not take part in version precedence.
if idx := strings.IndexByte(s, '+'); idx >= 0 {
s = s[:idx]
}
// Everything after the first hyphen is the prerelease suffix
// (e.g. "-dirty", "-rc1", "-beta.2").
var pre string
if idx := strings.IndexByte(s, '-'); idx >= 0 {
pre = s[idx+1:]
s = s[:idx]
}
parts := strings.Split(s, ".")
Expand All @@ -39,21 +51,108 @@ func ParseSemver(s string) (Semver, error) {
if err != nil {
return Semver{}, fmt.Errorf("invalid patch: %w", err)
}
return Semver{Major: major, Minor: minor, Patch: patch}, nil
return Semver{Major: major, Minor: minor, Patch: patch, Pre: pre}, nil
}

// NewerThan returns true if v is strictly newer than other.
func (v Semver) NewerThan(other Semver) bool {
if v.Major != other.Major {
return v.Major > other.Major
// Compare returns -1, 0 or +1 as v sorts before, equal to, or after other.
// Ordering follows semantic-version precedence: the numeric triple is
// compared first, then the prerelease suffix. A version carrying a
// prerelease suffix sorts before the same version without one, so
// "1.2.3-rc1" < "1.2.3".
func (v Semver) Compare(other Semver) int {
if c := compareInt(v.Major, other.Major); c != 0 {
return c
}
if v.Minor != other.Minor {
return v.Minor > other.Minor
if c := compareInt(v.Minor, other.Minor); c != 0 {
return c
}
return v.Patch > other.Patch
if c := compareInt(v.Patch, other.Patch); c != 0 {
return c
}
return comparePrerelease(v.Pre, other.Pre)
}

// NewerThan returns true if v is strictly newer than other.
func (v Semver) NewerThan(other Semver) bool {
return v.Compare(other) > 0
}

// String returns the version as "vMAJOR.MINOR.PATCH".
// String returns the version as "vMAJOR.MINOR.PATCH", with the prerelease
// suffix appended when present.
func (v Semver) String() string {
if v.Pre != "" {
return fmt.Sprintf("v%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre)
}
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
}

// comparePrerelease orders two prerelease suffixes. An empty suffix (a plain
// release) sorts after any non-empty one. Otherwise the suffixes are split on
// "." and compared identifier by identifier; when every shared identifier is
// equal, the suffix with more identifiers sorts later.
func comparePrerelease(a, b string) int {
if a == b {
return 0
}
if a == "" {
return 1
}
if b == "" {
return -1
}
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
for i := 0; i < len(as) && i < len(bs); i++ {
if c := comparePrereleaseIdent(as[i], bs[i]); c != 0 {
return c
}
}
return compareInt(len(as), len(bs))
}

// comparePrereleaseIdent orders two prerelease identifiers. All-digit
// identifiers are compared numerically and sort before alphanumeric ones;
// any other pair is compared bytewise.
func comparePrereleaseIdent(a, b string) int {
an, aNumeric := numericIdent(a)
bn, bNumeric := numericIdent(b)
switch {
case aNumeric && bNumeric:
return compareInt(an, bn)
case aNumeric:
return -1
case bNumeric:
return 1
default:
return strings.Compare(a, b)
}
}

// numericIdent reports whether s consists solely of digits, returning its
// numeric value when it does.
func numericIdent(s string) (int, bool) {
if s == "" {
return 0, false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0, false
}
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
}

func compareInt(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
17 changes: 11 additions & 6 deletions zz_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,13 @@ func TestSemver_NewerThan_AllBranches(t *testing.T) {
a, b Semver
want bool
}{
{Semver{2, 0, 0}, Semver{1, 9, 9}, true},
{Semver{1, 2, 0}, Semver{1, 1, 9}, true},
{Semver{1, 1, 2}, Semver{1, 1, 1}, true},
{Semver{1, 1, 1}, Semver{1, 1, 1}, false},
{Semver{1, 0, 0}, Semver{2, 0, 0}, false},
{Semver{2, 0, 0, ""}, Semver{1, 9, 9, ""}, true},
{Semver{1, 2, 0, ""}, Semver{1, 1, 9, ""}, true},
{Semver{1, 1, 2, ""}, Semver{1, 1, 1, ""}, true},
{Semver{1, 1, 1, ""}, Semver{1, 1, 1, ""}, false},
{Semver{1, 0, 0, ""}, Semver{2, 0, 0, ""}, false},
{Semver{1, 1, 1, ""}, Semver{1, 1, 1, "rc1"}, true},
{Semver{1, 1, 1, "rc1"}, Semver{1, 1, 1, ""}, false},
}
for _, tc := range cases {
if got := tc.a.NewerThan(tc.b); got != tc.want {
Expand All @@ -193,7 +195,10 @@ func TestSemver_NewerThan_AllBranches(t *testing.T) {
// TestSemver_String covers the stringer.
func TestSemver_String(t *testing.T) {
t.Parallel()
if got := (Semver{1, 2, 3}).String(); got != "v1.2.3" {
if got := (Semver{1, 2, 3, ""}).String(); got != "v1.2.3" {
t.Errorf("String = %q", got)
}
if got := (Semver{1, 2, 3, "beta.1"}).String(); got != "v1.2.3-beta.1" {
t.Errorf("String = %q", got)
}
}
Expand Down
70 changes: 63 additions & 7 deletions zz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ func TestParseSemver(t *testing.T) {
want Semver
wantErr bool
}{
{"v1.2.3", Semver{1, 2, 3}, false},
{"1.2.3", Semver{1, 2, 3}, false},
{"v0.0.1", Semver{0, 0, 1}, false},
{"v10.20.30", Semver{10, 20, 30}, false},
{"v1.2.3-dirty", Semver{1, 2, 3}, false},
{"v1.6.2-rc1", Semver{1, 6, 2}, false},
{"v1.2.3", Semver{1, 2, 3, ""}, false},
{"1.2.3", Semver{1, 2, 3, ""}, false},
{"v0.0.1", Semver{0, 0, 1, ""}, false},
{"v10.20.30", Semver{10, 20, 30, ""}, false},
{"v1.2.3-dirty", Semver{1, 2, 3, "dirty"}, false},
{"v1.6.2-rc1", Semver{1, 6, 2, "rc1"}, false},
{"v1.2.3-beta.2", Semver{1, 2, 3, "beta.2"}, false},
{"v1.2.3+build.7", Semver{1, 2, 3, ""}, false},
{"v1.2.3-rc.1+build.7", Semver{1, 2, 3, "rc.1"}, false},
{"", Semver{}, true},
{"v1.2", Semver{}, true},
{"v1.2.x", Semver{}, true},
Expand Down Expand Up @@ -77,6 +80,21 @@ func TestSemverNewerThan(t *testing.T) {
{"v1.2.3", "v1.3.0", false},
{"v1.2.3", "v2.0.0", false},
{"v0.0.1", "v0.0.0", true},

// A prerelease sorts below the release it leads up to.
{"v1.2.3", "v1.2.3-rc1", true},
{"v1.2.3-rc1", "v1.2.3", false},
{"v1.2.3-rc1", "v1.2.3-rc1", false},
{"v1.2.3-rc2", "v1.2.3-rc1", true},
{"v1.2.3-rc1", "v1.2.3-rc2", false},
{"v1.2.3-beta.2", "v1.2.3-beta.1", true},
{"v1.2.3-rc.1", "v1.2.3-beta.9", true},
{"v1.2.3-rc.1", "v1.2.2", true},
{"v1.2.3-rc.1", "v1.2.4", false},
{"v1.2.3-rc.1.1", "v1.2.3-rc.1", true},
{"v1.2.3-1", "v1.2.3-alpha", false},
{"v1.2.3-alpha", "v1.2.3-1", true},
{"v1.2.3+build.7", "v1.2.3", false},
}

for _, tt := range tests {
Expand All @@ -93,10 +111,48 @@ func TestSemverNewerThan(t *testing.T) {

func TestSemverString(t *testing.T) {
t.Parallel()
v := Semver{1, 6, 3}
v := Semver{1, 6, 3, ""}
if s := v.String(); s != "v1.6.3" {
t.Fatalf("String() = %q, want %q", s, "v1.6.3")
}
if s := (Semver{1, 6, 3, "rc1"}).String(); s != "v1.6.3-rc1" {
t.Fatalf("String() = %q, want %q", s, "v1.6.3-rc1")
}
}

// TestSemverPinEquality checks that an exact pin only matches a tag with the
// same prerelease suffix, which is how checkPinnedVersion decides whether the
// pinned release is already installed.
func TestSemverPinEquality(t *testing.T) {
t.Parallel()
tests := []struct {
pin, installed string
want bool
}{
{"v1.2.3", "v1.2.3", true},
{"v1.2.3", "v1.2.3-rc1", false},
{"v1.2.3-rc1", "v1.2.3", false},
{"v1.2.3-rc1", "v1.2.3-rc1", true},
{"v1.2.3-rc1", "v1.2.3-rc2", false},
{"v1.2.3", "v1.2.3+build.7", true},
}

for _, tt := range tests {
name := fmt.Sprintf("%s==%s", tt.pin, tt.installed)
t.Run(name, func(t *testing.T) {
pin, err := ParseSemver(tt.pin)
if err != nil {
t.Fatalf("ParseSemver(%q): %v", tt.pin, err)
}
installed, err := ParseSemver(tt.installed)
if err != nil {
t.Fatalf("ParseSemver(%q): %v", tt.installed, err)
}
if got := installed == pin; got != tt.want {
t.Fatalf("%q == %q = %v, want %v", tt.installed, tt.pin, got, tt.want)
}
})
}
}

func TestVerifyChecksum(t *testing.T) {
Expand Down