From f4bd06e71f95d7025f8602ec7a2cace6bcc25e6d Mon Sep 17 00:00:00 2001 From: Lasse Gaardsholt Date: Thu, 4 Dec 2025 14:46:04 +0100 Subject: [PATCH] added support for prefix of any character followed by "-" Signed-off-by: Lasse Gaardsholt --- .circleci/config.yml | 58 ---------- .github/CODEOWNERS | 13 +++ .github/dependabot.yml | 22 ++++ .github/pull_request_template.md | 12 ++ .github/workflows/go-tests.yml | 86 ++++++++++++++ CHANGELOG.md | 87 ++++++++++++++ LICENSE | 2 + README.md | 6 +- constraint.go | 163 +++++++++++++++++++++----- constraint_test.go | 132 +++++++++++++++++++++ version.go | 147 ++++++++++++++++++------ version_collection.go | 3 + version_collection_test.go | 3 + version_test.go | 190 +++++++++++++++++++++++++++++-- 14 files changed, 792 insertions(+), 132 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/go-tests.yml create mode 100644 CHANGELOG.md diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 11d5035..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,58 +0,0 @@ -version: 2.1 - -references: - images: - go: &GOLANG_IMAGE circleci/golang:latest - environments: - tmp: &TEST_RESULTS_PATH /tmp/test-results # path to where test results are saved - -# reusable 'executor' object for jobs -executors: - go: - docker: - - image: *GOLANG_IMAGE - environment: - - TEST_RESULTS: *TEST_RESULTS_PATH - -jobs: - go-test: - executor: go - steps: - - checkout - - run: mkdir -p $TEST_RESULTS - - - restore_cache: # restore cache from dev-build job - keys: - - go-version-modcache-v1-{{ checksum "go.mod" }} - - # Save go module cache if the go.mod file has changed - - save_cache: - key: go-version-modcache-v1-{{ checksum "go.mod" }} - paths: - - "/go/pkg/mod" - - # check go fmt output because it does not report non-zero when there are fmt changes - - run: - name: check go fmt - command: | - files=$(go fmt ./...) - if [ -n "$files" ]; then - echo "The following file(s) do not conform to go fmt:" - echo "$files" - exit 1 - fi - - # run go tests with gotestsum - - run: | - PACKAGE_NAMES=$(go list ./...) - gotestsum --format=short-verbose --junitfile $TEST_RESULTS/gotestsum-report.xml -- $PACKAGE_NAMES - - store_test_results: - path: *TEST_RESULTS_PATH - - store_artifacts: - path: *TEST_RESULTS_PATH - -workflows: - version: 2 - test-and-build: - jobs: - - go-test diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3da71e0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Each line is a file pattern followed by one or more owners. +# More on CODEOWNERS files: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners + +# Default owner +* @hashicorp/team-ip-compliance + +# Add override rules below. Each line is a file/folder pattern followed by one or more owners. +# Being an owner means those groups or individuals will be added as reviewers to PRs affecting +# those areas of the code. +# Examples: +# /docs/ @docs-team +# *.js @js-team +# *.go @go-team \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b763838 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + labels: ["dependencies"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - dependencies + groups: + github-actions-breaking: + update-types: + - major + github-actions-backward-compatible: + update-types: + - minor + - patch diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..877c03e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ + +## Description + + + +## Related Issue + + + +## How Has This Been Tested? + + diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml new file mode 100644 index 0000000..34a4771 --- /dev/null +++ b/.github/workflows/go-tests.yml @@ -0,0 +1,86 @@ +name: go-tests + +on: [push] + +env: + TEST_RESULTS: /tmp/test-results + +jobs: + + go-tests: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ 1.15.3, 1.19 ] + + steps: + - name: Setup go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: ${{ matrix.go-version }} + + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Create test directory + run: | + mkdir -p ${{ env.TEST_RESULTS }} + + - name: Download go modules + run: go mod download + + - name: Cache / restore go modules + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + # Check go fmt output because it does not report non-zero when there are fmt changes + - name: Run gofmt + run: | + go fmt ./... + files=$(go fmt ./...) + if [ -n "$files" ]; then + echo "The following file(s) do not conform to go fmt:" + echo "$files" + exit 1 + fi + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 + + # Install gotestsum with go get for 1.15.3; otherwise default to go install + - name: Install gotestsum + run: | + GTS="gotest.tools/gotestsum@v1.8.2" + # We use the same error message prefix in either failure case, so just define it once here. + ERROR="Failed to install $GTS" + # First try to 'go install', if that fails try 'go get'... + go install "$GTS" || go get "$GTS" || { echo "$ERROR: both 'go install' and 'go get' failed"; exit 1; } + # Check that the gotestsum command was actually installed in the path... + command -v gotestsum > /dev/null 2>&1 || { echo "$ERROR: gotestsum command not installed"; exit 1; } + echo "OK: Command 'gotestsum' installed ($GTS)" + + - name: Run go tests + run: | + PACKAGE_NAMES=$(go list ./...) + gotestsum --format=short-verbose --junitfile $TEST_RESULTS/gotestsum-report.xml -- -p 2 -cover -coverprofile=coverage.out $PACKAGE_NAMES + + # Save coverage report parts + - name: Upload and save artifacts + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Test Results-${{matrix.go-version}} + path: ${{ env.TEST_RESULTS }} + + - name: Upload coverage report + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + path: coverage.out + name: Coverage-report-${{matrix.go-version}} + + - name: Display coverage report + run: go tool cover -func=coverage.out diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be799cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# 1.8.0 (Nov 28, 2025) + +ENHANCEMENTS: + +- Add benchmark test for version.String() in https://github.com/hashicorp/go-version/pull/159 +- Bytes implementation in https://github.com/hashicorp/go-version/pull/161 + +INTERNAL: + +- Add CODEOWNERS file in .github/CODEOWNERS in https://github.com/hashicorp/go-version/pull/145 +- Linting in https://github.com/hashicorp/go-version/pull/151 +- Correct typos in comments in https://github.com/hashicorp/go-version/pull/134 +- Migrate GitHub Actions updates from TSCCR to Dependabot in https://github.com/hashicorp/go-version/pull/155 +- Bump the github-actions-backward-compatible group with 2 updates in https://github.com/hashicorp/go-version/pull/157 +- Update doc reference in README in https://github.com/hashicorp/go-version/pull/135 +- Bump the github-actions-breaking group with 3 updates in https://github.com/hashicorp/go-version/pull/156 +- [Compliance] - PR Template Changes Required in https://github.com/hashicorp/go-version/pull/158 +- Bump actions/cache from 4.2.3 to 4.2.4 in the github-actions-backward-compatible group in https://github.com/hashicorp/go-version/pull/167 +- Bump actions/checkout from 4.2.2 to 5.0.0 in the github-actions-breaking group in https://github.com/hashicorp/go-version/pull/166 +- Bump the github-actions-breaking group across 1 directory with 2 updates in https://github.com/hashicorp/go-version/pull/171 +- [IND-4226] [COMPLIANCE] Update Copyright Headers in https://github.com/hashicorp/go-version/pull/172 +- drop init() in https://github.com/hashicorp/go-version/pull/175 + +# 1.7.0 (May 24, 2024) + +ENHANCEMENTS: + +- Remove `reflect` dependency ([#91](https://github.com/hashicorp/go-version/pull/91)) +- Implement the `database/sql.Scanner` and `database/sql/driver.Value` interfaces for `Version` ([#133](https://github.com/hashicorp/go-version/pull/133)) + +INTERNAL: + +- [COMPLIANCE] Add Copyright and License Headers ([#115](https://github.com/hashicorp/go-version/pull/115)) +- [COMPLIANCE] Update MPL-2.0 LICENSE ([#105](https://github.com/hashicorp/go-version/pull/105)) +- Bump actions/cache from 3.0.11 to 3.2.5 ([#116](https://github.com/hashicorp/go-version/pull/116)) +- Bump actions/checkout from 3.2.0 to 3.3.0 ([#111](https://github.com/hashicorp/go-version/pull/111)) +- Bump actions/upload-artifact from 3.1.1 to 3.1.2 ([#112](https://github.com/hashicorp/go-version/pull/112)) +- GHA Migration ([#103](https://github.com/hashicorp/go-version/pull/103)) +- github: Pin external GitHub Actions to hashes ([#107](https://github.com/hashicorp/go-version/pull/107)) +- SEC-090: Automated trusted workflow pinning (2023-04-05) ([#124](https://github.com/hashicorp/go-version/pull/124)) +- update readme ([#104](https://github.com/hashicorp/go-version/pull/104)) + +# 1.6.0 (June 28, 2022) + +FEATURES: + +- Add `Prerelease` function to `Constraint` to return true if the version includes a prerelease field ([#100](https://github.com/hashicorp/go-version/pull/100)) + +# 1.5.0 (May 18, 2022) + +FEATURES: + +- Use `encoding` `TextMarshaler` & `TextUnmarshaler` instead of JSON equivalents ([#95](https://github.com/hashicorp/go-version/pull/95)) +- Add JSON handlers to allow parsing from/to JSON ([#93](https://github.com/hashicorp/go-version/pull/93)) + +# 1.4.0 (January 5, 2022) + +FEATURES: + + - Introduce `MustConstraints()` ([#87](https://github.com/hashicorp/go-version/pull/87)) + - `Constraints`: Introduce `Equals()` and `sort.Interface` methods ([#88](https://github.com/hashicorp/go-version/pull/88)) + +# 1.3.0 (March 31, 2021) + +Please note that CHANGELOG.md does not exist in the source code prior to this release. + +FEATURES: + - Add `Core` function to return a version without prerelease or metadata ([#85](https://github.com/hashicorp/go-version/pull/85)) + +# 1.2.1 (June 17, 2020) + +BUG FIXES: + - Prevent `Version.Equal` method from panicking on `nil` encounter ([#73](https://github.com/hashicorp/go-version/pull/73)) + +# 1.2.0 (April 23, 2019) + +FEATURES: + - Add `GreaterThanOrEqual` and `LessThanOrEqual` helper methods ([#53](https://github.com/hashicorp/go-version/pull/53)) + +# 1.1.0 (Jan 07, 2019) + +FEATURES: + - Add `NewSemver` constructor ([#45](https://github.com/hashicorp/go-version/pull/45)) + +# 1.0.0 (August 24, 2018) + +Initial release. diff --git a/LICENSE b/LICENSE index c33dcc7..bb1e9a4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,5 @@ +Copyright IBM Corp. 2014, 2025 + Mozilla Public License, version 2.0 1. Definitions diff --git a/README.md b/README.md index 2ee50c5..83a8249 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Versioning Library for Go -[![Build Status](https://circleci.com/gh/hashicorp/go-version/tree/master.svg?style=svg)](https://circleci.com/gh/hashicorp/go-version/tree/master) + +![Build Status](https://github.com/hashicorp/go-version/actions/workflows/go-tests.yml/badge.svg) +[![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/go-version.svg)](https://pkg.go.dev/github.com/hashicorp/go-version) go-version is a library for parsing versions and version constraints, and verifying versions against a set of constraints. go-version @@ -11,7 +13,7 @@ Versions used with go-version must follow [SemVer](http://semver.org/). ## Installation and Usage Package documentation can be found on -[GoDoc](http://godoc.org/github.com/hashicorp/go-version). +[Go Reference](https://pkg.go.dev/github.com/hashicorp/go-version). Installation can be done with a normal `go get`: diff --git a/constraint.go b/constraint.go index d055759..3964da0 100644 --- a/constraint.go +++ b/constraint.go @@ -1,51 +1,55 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + package version import ( "fmt" - "reflect" "regexp" + "sort" "strings" + "sync" +) + +var ( + constraintRegexp *regexp.Regexp + constraintRegexpOnce sync.Once ) +func getConstraintRegexp() *regexp.Regexp { + constraintRegexpOnce.Do(func() { + // This heavy lifting only happens the first time this function is called + constraintRegexp = regexp.MustCompile(fmt.Sprintf( + `^\s*(%s)\s*(%s)\s*$`, + `<=|>=|!=|~>|<|>|=|`, + VersionRegexpRaw, + )) + }) + return constraintRegexp +} + // Constraint represents a single constraint for a version, such as // ">= 1.0". type Constraint struct { f constraintFunc + op operator check *Version original string } +func (c *Constraint) Equals(con *Constraint) bool { + return c.op == con.op && c.check.Equal(con.check) +} + // Constraints is a slice of constraints. We make a custom type so that // we can add methods to it. type Constraints []*Constraint type constraintFunc func(v, c *Version) bool -var constraintOperators map[string]constraintFunc - -var constraintRegexp *regexp.Regexp - -func init() { - constraintOperators = map[string]constraintFunc{ - "": constraintEqual, - "=": constraintEqual, - "!=": constraintNotEqual, - ">": constraintGreaterThan, - "<": constraintLessThan, - ">=": constraintGreaterThanEqual, - "<=": constraintLessThanEqual, - "~>": constraintPessimistic, - } - - ops := make([]string, 0, len(constraintOperators)) - for k := range constraintOperators { - ops = append(ops, regexp.QuoteMeta(k)) - } - - constraintRegexp = regexp.MustCompile(fmt.Sprintf( - `^\s*(%s)\s*(%s)\s*$`, - strings.Join(ops, "|"), - VersionRegexpRaw)) +type constraintOperation struct { + op operator + f constraintFunc } // NewConstraint will parse one or more constraints from the given @@ -66,6 +70,16 @@ func NewConstraint(v string) (Constraints, error) { return Constraints(result), nil } +// MustConstraints is a helper that wraps a call to a function +// returning (Constraints, error) and panics if error is non-nil. +func MustConstraints(c Constraints, err error) Constraints { + if err != nil { + panic(err) + } + + return c +} + // Check tests if a version satisfies all the constraints. func (cs Constraints) Check(v *Version) bool { for _, c := range cs { @@ -77,6 +91,56 @@ func (cs Constraints) Check(v *Version) bool { return true } +// Equals compares Constraints with other Constraints +// for equality. This may not represent logical equivalence +// of compared constraints. +// e.g. even though '>0.1,>0.2' is logically equivalent +// to '>0.2' it is *NOT* treated as equal. +// +// Missing operator is treated as equal to '=', whitespaces +// are ignored and constraints are sorted before comparison. +func (cs Constraints) Equals(c Constraints) bool { + if len(cs) != len(c) { + return false + } + + // make copies to retain order of the original slices + left := make(Constraints, len(cs)) + copy(left, cs) + sort.Stable(left) + right := make(Constraints, len(c)) + copy(right, c) + sort.Stable(right) + + // compare sorted slices + for i, con := range left { + if !con.Equals(right[i]) { + return false + } + } + + return true +} + +func (cs Constraints) Len() int { + return len(cs) +} + +func (cs Constraints) Less(i, j int) bool { + if cs[i].op < cs[j].op { + return true + } + if cs[i].op > cs[j].op { + return false + } + + return cs[i].check.LessThan(cs[j].check) +} + +func (cs Constraints) Swap(i, j int) { + cs[i], cs[j] = cs[j], cs[i] +} + // Returns the string format of the constraints func (cs Constraints) String() string { csStr := make([]string, len(cs)) @@ -92,14 +156,20 @@ func (c *Constraint) Check(v *Version) bool { return c.f(v, c.check) } +// Prerelease returns true if the version underlying this constraint +// contains a prerelease field. +func (c *Constraint) Prerelease() bool { + return len(c.check.Prerelease()) > 0 +} + func (c *Constraint) String() string { return c.original } func parseSingle(v string) (*Constraint, error) { - matches := constraintRegexp.FindStringSubmatch(v) + matches := getConstraintRegexp().FindStringSubmatch(v) if matches == nil { - return nil, fmt.Errorf("Malformed constraint: %s", v) + return nil, fmt.Errorf("malformed constraint: %s", v) } check, err := NewVersion(matches[2]) @@ -107,8 +177,29 @@ func parseSingle(v string) (*Constraint, error) { return nil, err } + var cop constraintOperation + switch matches[1] { + case "=": + cop = constraintOperation{op: equal, f: constraintEqual} + case "!=": + cop = constraintOperation{op: notEqual, f: constraintNotEqual} + case ">": + cop = constraintOperation{op: greaterThan, f: constraintGreaterThan} + case "<": + cop = constraintOperation{op: lessThan, f: constraintLessThan} + case ">=": + cop = constraintOperation{op: greaterThanEqual, f: constraintGreaterThanEqual} + case "<=": + cop = constraintOperation{op: lessThanEqual, f: constraintLessThanEqual} + case "~>": + cop = constraintOperation{op: pessimistic, f: constraintPessimistic} + default: + cop = constraintOperation{op: equal, f: constraintEqual} + } + return &Constraint{ - f: constraintOperators[matches[1]], + f: cop.f, + op: cop.op, check: check, original: v, }, nil @@ -119,7 +210,7 @@ func prereleaseCheck(v, c *Version) bool { case cPre && vPre: // A constraint with a pre-release can only match a pre-release version // with the same base segments. - return reflect.DeepEqual(c.Segments64(), v.Segments64()) + return v.equalSegments(c) case !cPre && vPre: // A constraint without a pre-release can only match a version without a @@ -138,6 +229,18 @@ func prereleaseCheck(v, c *Version) bool { // Constraint functions //------------------------------------------------------------------- +type operator rune + +const ( + equal operator = '=' + notEqual operator = '≠' + greaterThan operator = '>' + lessThan operator = '<' + greaterThanEqual operator = '≥' + lessThanEqual operator = '≤' + pessimistic operator = '~' +) + func constraintEqual(v, c *Version) bool { return v.Equal(c) } diff --git a/constraint_test.go b/constraint_test.go index 9c5bee3..6f3a520 100644 --- a/constraint_test.go +++ b/constraint_test.go @@ -1,6 +1,12 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + package version import ( + "fmt" + "reflect" + "sort" "testing" ) @@ -97,6 +103,132 @@ func TestConstraintCheck(t *testing.T) { } } +func TestConstraintPrerelease(t *testing.T) { + cases := []struct { + constraint string + prerelease bool + }{ + {"= 1.0", false}, + {"= 1.0-beta", true}, + {"~> 2.1.0", false}, + {"~> 2.1.0-dev", true}, + {"> 2.0", false}, + {">= 2.1.0-a", true}, + } + + for _, tc := range cases { + c, err := parseSingle(tc.constraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := c.Prerelease() + expected := tc.prerelease + if actual != expected { + t.Fatalf("Constraint: %s\nExpected: %#v", + tc.constraint, expected) + } + } +} + +func TestConstraintEqual(t *testing.T) { + cases := []struct { + leftConstraint string + rightConstraint string + expectedEqual bool + }{ + { + "0.0.1", + "0.0.1", + true, + }, + { // whitespaces + " 0.0.1 ", + "0.0.1", + true, + }, + { // equal op implied + "=0.0.1 ", + "0.0.1", + true, + }, + { // version difference + "=0.0.1", + "=0.0.2", + false, + }, + { // operator difference + ">0.0.1", + "=0.0.1", + false, + }, + { // different order + ">0.1.0, <=1.0.0", + "<=1.0.0, >0.1.0", + true, + }, + } + + for _, tc := range cases { + leftCon, err := NewConstraint(tc.leftConstraint) + if err != nil { + t.Fatalf("err: %s", err) + } + rightCon, err := NewConstraint(tc.rightConstraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := leftCon.Equals(rightCon) + if actual != tc.expectedEqual { + t.Fatalf("Constraints: %s vs %s\nExpected: %t\nActual: %t", + tc.leftConstraint, tc.rightConstraint, tc.expectedEqual, actual) + } + } +} + +func TestConstraint_sort(t *testing.T) { + cases := []struct { + constraint string + expectedConstraints string + }{ + { + ">= 0.1.0,< 1.12", + "< 1.12,>= 0.1.0", + }, + { + "< 1.12,>= 0.1.0", + "< 1.12,>= 0.1.0", + }, + { + "< 1.12,>= 0.1.0,0.2.0", + "< 1.12,0.2.0,>= 0.1.0", + }, + { + ">1.0,>0.1.0,>0.3.0,>0.2.0", + ">0.1.0,>0.2.0,>0.3.0,>1.0", + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + c, err := NewConstraint(tc.constraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + sort.Sort(c) + + actual := c.String() + + if !reflect.DeepEqual(actual, tc.expectedConstraints) { + t.Fatalf("unexpected order\nexpected: %#v\nactual: %#v", + tc.expectedConstraints, actual) + } + }) + } +} + func TestConstraintsString(t *testing.T) { cases := []struct { constraint string diff --git a/version.go b/version.go index 30454e1..4f8db24 100644 --- a/version.go +++ b/version.go @@ -1,30 +1,49 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + package version import ( - "bytes" + "database/sql/driver" "fmt" - "reflect" "regexp" "strconv" "strings" + "sync" ) // The compiled regular expression used to test the validity of a version. var ( - versionRegexp *regexp.Regexp - semverRegexp *regexp.Regexp + versionRegexp *regexp.Regexp + versionRegexpOnce sync.Once + semverRegexp *regexp.Regexp + semverRegexpOnce sync.Once ) +func getVersionRegexp() *regexp.Regexp { + versionRegexpOnce.Do(func() { + versionRegexp = regexp.MustCompile("^" + VersionRegexpRaw + "$") + }) + return versionRegexp +} + +func getSemverRegexp() *regexp.Regexp { + semverRegexpOnce.Do(func() { + semverRegexp = regexp.MustCompile("^" + SemverRegexpRaw + "$") + }) + return semverRegexp +} + // The raw regular expression string used for testing the validity // of a version. const ( - VersionRegexpRaw string = `v?([0-9]+(\.[0-9]+)*?)` + + VersionRegexpRaw string = `(?:\w+\-)*v?([0-9]+(\.[0-9]+)*?)` + `(-([0-9]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)|(-?([A-Za-z\-~]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)))?` + `(\+([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))?` + `?` // SemverRegexpRaw requires a separator between version and prerelease - SemverRegexpRaw string = `v?([0-9]+(\.[0-9]+)*?)` + + SemverRegexpRaw string = `(?:\w+\-)*v?([0-9]+(\.[0-9]+)*?)` + `(-([0-9]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)|(-([A-Za-z\-~]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)))?` + `(\+([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))?` + `?` @@ -39,41 +58,34 @@ type Version struct { original string } -func init() { - versionRegexp = regexp.MustCompile("^" + VersionRegexpRaw + "$") - semverRegexp = regexp.MustCompile("^" + SemverRegexpRaw + "$") -} - // NewVersion parses the given version and returns a new // Version. func NewVersion(v string) (*Version, error) { - return newVersion(v, versionRegexp) + return newVersion(v, getVersionRegexp()) } // NewSemver parses the given version and returns a new // Version that adheres strictly to SemVer specs // https://semver.org/ func NewSemver(v string) (*Version, error) { - return newVersion(v, semverRegexp) + return newVersion(v, getSemverRegexp()) } func newVersion(v string, pattern *regexp.Regexp) (*Version, error) { matches := pattern.FindStringSubmatch(v) if matches == nil { - return nil, fmt.Errorf("Malformed version: %s", v) + return nil, fmt.Errorf("malformed version: %s", v) } segmentsStr := strings.Split(matches[1], ".") segments := make([]int64, len(segmentsStr)) - si := 0 for i, str := range segmentsStr { val, err := strconv.ParseInt(str, 10, 64) if err != nil { return nil, fmt.Errorf( - "Error parsing version: %s", err) + "error parsing version: %s", err) } - segments[i] = int64(val) - si++ + segments[i] = val } // Even though we could support more than three segments, if we @@ -92,7 +104,7 @@ func newVersion(v string, pattern *regexp.Regexp) (*Version, error) { metadata: matches[10], pre: pre, segments: segments, - si: si, + si: len(segmentsStr), original: v, }, nil } @@ -119,11 +131,8 @@ func (v *Version) Compare(other *Version) int { return 0 } - segmentsSelf := v.Segments64() - segmentsOther := other.Segments64() - // If the segments are the same, we must compare on prerelease info - if reflect.DeepEqual(segmentsSelf, segmentsOther) { + if v.equalSegments(other) { preSelf := v.Prerelease() preOther := other.Prerelease() if preSelf == "" && preOther == "" { @@ -139,6 +148,8 @@ func (v *Version) Compare(other *Version) int { return comparePrereleases(preSelf, preOther) } + segmentsSelf := v.Segments64() + segmentsOther := other.Segments64() // Get the highest specificity (hS), or if they're equal, just use segmentSelf length lenSelf := len(segmentsSelf) lenOther := len(segmentsOther) @@ -162,7 +173,7 @@ func (v *Version) Compare(other *Version) int { // this means Other had the lower specificity // Check to see if the remaining segments in Self are all zeros - if !allZero(segmentsSelf[i:]) { - //if not, it means that Self has to be greater than Other + // if not, it means that Self has to be greater than Other return 1 } break @@ -174,7 +185,7 @@ func (v *Version) Compare(other *Version) int { } else if lhs < rhs { return -1 } - // Otherwis, rhs was > lhs, they're not equal + // Otherwise, rhs was > lhs, they're not equal return 1 } @@ -182,6 +193,21 @@ func (v *Version) Compare(other *Version) int { return 0 } +func (v *Version) equalSegments(other *Version) bool { + segmentsSelf := v.Segments64() + segmentsOther := other.Segments64() + + if len(segmentsSelf) != len(segmentsOther) { + return false + } + for i, v := range segmentsSelf { + if v != segmentsOther[i] { + return false + } + } + return true +} + func allZero(segs []int64) bool { for _, s := range segs { if s != 0 { @@ -278,8 +304,20 @@ func comparePrereleases(v string, other string) int { return 0 } +// Core returns a new version constructed from only the MAJOR.MINOR.PATCH +// segments of the version, without prerelease or metadata. +func (v *Version) Core() *Version { + segments := v.Segments64() + segmentsOnly := fmt.Sprintf("%d.%d.%d", segments[0], segments[1], segments[2]) + return Must(NewVersion(segmentsOnly)) +} + // Equal tests if two versions are equal. func (v *Version) Equal(o *Version) bool { + if v == nil || o == nil { + return v == o + } + return v.Compare(o) == 0 } @@ -355,22 +393,29 @@ func (v *Version) Segments64() []int64 { // missing parts (1.0 => 1.0.0) will be made into a canonicalized form // as shown in the parenthesized examples. func (v *Version) String() string { - var buf bytes.Buffer - fmtParts := make([]string, len(v.segments)) + return string(v.bytes()) +} + +func (v *Version) bytes() []byte { + var buf []byte for i, s := range v.segments { - // We can ignore err here since we've pre-parsed the values in segments - str := strconv.FormatInt(s, 10) - fmtParts[i] = str + if i > 0 { + buf = append(buf, '.') + } + buf = strconv.AppendInt(buf, s, 10) } - fmt.Fprintf(&buf, strings.Join(fmtParts, ".")) + if v.pre != "" { - fmt.Fprintf(&buf, "-%s", v.pre) + buf = append(buf, '-') + buf = append(buf, v.pre...) } + if v.metadata != "" { - fmt.Fprintf(&buf, "+%s", v.metadata) + buf = append(buf, '+') + buf = append(buf, v.metadata...) } - return buf.String() + return buf } // Original returns the original parsed version as-is, including any @@ -378,3 +423,37 @@ func (v *Version) String() string { func (v *Version) Original() string { return v.original } + +// UnmarshalText implements encoding.TextUnmarshaler interface. +func (v *Version) UnmarshalText(b []byte) error { + temp, err := NewVersion(string(b)) + if err != nil { + return err + } + + *v = *temp + + return nil +} + +// MarshalText implements encoding.TextMarshaler interface. +func (v *Version) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +// Scan implements the sql.Scanner interface. +func (v *Version) Scan(src interface{}) error { + switch src := src.(type) { + case string: + return v.UnmarshalText([]byte(src)) + case nil: + return nil + default: + return fmt.Errorf("cannot scan %T as Version", src) + } +} + +// Value implements the driver.Valuer interface. +func (v *Version) Value() (driver.Value, error) { + return v.String(), nil +} diff --git a/version_collection.go b/version_collection.go index cc888d4..11bc8b1 100644 --- a/version_collection.go +++ b/version_collection.go @@ -1,3 +1,6 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + package version // Collection is a type that implements the sort.Interface interface diff --git a/version_collection_test.go b/version_collection_test.go index 14783d7..cb0749a 100644 --- a/version_collection_test.go +++ b/version_collection_test.go @@ -1,3 +1,6 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + package version import ( diff --git a/version_test.go b/version_test.go index bd3534a..10ae60f 100644 --- a/version_test.go +++ b/version_test.go @@ -1,6 +1,11 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + package version import ( + "encoding/json" + "fmt" "reflect" "testing" ) @@ -21,19 +26,21 @@ func TestNewVersion(t *testing.T) { {"1.2-beta.5", false}, {"\n1.2", true}, {"1.2.0-x.Y.0+metadata", false}, - {"1.2.0-x.Y.0+metadata-width-hypen", false}, - {"1.2.3-rc1-with-hypen", false}, + {"1.2.0-x.Y.0+metadata-width-hyphen", false}, + {"1.2.3-rc1-with-hyphen", false}, {"1.2.3.4", false}, {"1.2.0.4-x.Y.0+metadata", false}, - {"1.2.0.4-x.Y.0+metadata-width-hypen", false}, + {"1.2.0.4-x.Y.0+metadata-width-hyphen", false}, {"1.2.0-X-1.2.0+metadata~dist", false}, - {"1.2.3.4-rc1-with-hypen", false}, + {"1.2.3.4-rc1-with-hyphen", false}, {"1.2.3.4", false}, {"v1.2.3", false}, {"foo1.2.3", true}, {"1.7rc2", false}, {"v1.7rc2", false}, {"1.0-", false}, + {"controller-v0.40.2", false}, + {"azure-cli-v1.4.2", false}, } for _, tc := range cases { @@ -62,19 +69,21 @@ func TestNewSemver(t *testing.T) { {"1.2-beta.5", false}, {"\n1.2", true}, {"1.2.0-x.Y.0+metadata", false}, - {"1.2.0-x.Y.0+metadata-width-hypen", false}, - {"1.2.3-rc1-with-hypen", false}, + {"1.2.0-x.Y.0+metadata-width-hyphen", false}, + {"1.2.3-rc1-with-hyphen", false}, {"1.2.3.4", false}, {"1.2.0.4-x.Y.0+metadata", false}, - {"1.2.0.4-x.Y.0+metadata-width-hypen", false}, + {"1.2.0.4-x.Y.0+metadata-width-hyphen", false}, {"1.2.0-X-1.2.0+metadata~dist", false}, - {"1.2.3.4-rc1-with-hypen", false}, + {"1.2.3.4-rc1-with-hyphen", false}, {"1.2.3.4", false}, {"v1.2.3", false}, {"foo1.2.3", true}, {"1.7rc2", true}, {"v1.7rc2", true}, {"1.0-", true}, + {"controller-v0.40.2", false}, + {"azure-cli-v1.4.2", false}, } for _, tc := range cases { @@ -87,6 +96,38 @@ func TestNewSemver(t *testing.T) { } } +func TestCore(t *testing.T) { + cases := []struct { + v1 string + v2 string + }{ + {"1.2.3", "1.2.3"}, + {"2.3.4-alpha1", "2.3.4"}, + {"3.4.5alpha1", "3.4.5"}, + {"1.2.3-2", "1.2.3"}, + {"4.5.6-beta1+meta", "4.5.6"}, + {"5.6.7.1.2.3", "5.6.7"}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("error for version %q: %s", tc.v1, err) + } + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("error for version %q: %s", tc.v2, err) + } + + actual := v1.Core() + expected := v2 + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected: %s\nactual: %s", expected, actual) + } + } +} + func TestVersionCompare(t *testing.T) { cases := []struct { v1 string @@ -110,6 +151,12 @@ func TestVersionCompare(t *testing.T) { {"1.7rc2", "1.7rc1", 1}, {"1.7rc2", "1.7", -1}, {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", 1}, + {"controller-v0.40.2", "controller-v0.40.3", -1}, + {"0.40.4", "controller-v0.40.2", 1}, + {"0.40.4", "controller-v0.40.4", 0}, + {"azure-cli-v1.4.2", "azure-cli-v1.4.2", 0}, + {"azure-cli-v1.4.1", "azure-cli-v1.4.2", -1}, + {"1.4.3", "azure-cli-v1.4.2", 1}, } for _, tc := range cases { @@ -172,6 +219,32 @@ func TestVersionCompare_versionAndSemver(t *testing.T) { } } +func TestVersionEqual_nil(t *testing.T) { + mustVersion := func(v string) *Version { + ver, err := NewVersion(v) + if err != nil { + t.Fatal(err) + } + return ver + } + cases := []struct { + leftVersion *Version + rightVersion *Version + expected bool + }{ + {mustVersion("1.0.0"), nil, false}, + {nil, mustVersion("1.0.0"), false}, + {nil, nil, true}, + } + + for _, tc := range cases { + given := tc.leftVersion.Equal(tc.rightVersion) + if given != tc.expected { + t.Fatalf("expected Equal to nil to be %t", tc.expected) + } + } +} + func TestComparePreReleases(t *testing.T) { cases := []struct { v1 string @@ -335,6 +408,75 @@ func TestVersionSegments64(t *testing.T) { } } +func TestJsonMarshal(t *testing.T) { + cases := []struct { + version string + err bool + }{ + {"1.2.3", false}, + {"1.2.0-x.Y.0+metadata", false}, + {"1.2.0-x.Y.0+metadata-width-hyphen", false}, + {"1.2.3-rc1-with-hyphen", false}, + {"1.2.3.4", false}, + {"1.2.0.4-x.Y.0+metadata", false}, + {"1.2.0.4-x.Y.0+metadata-width-hyphen", false}, + {"1.2.0-X-1.2.0+metadata~dist", false}, + {"1.2.3.4-rc1-with-hyphen", false}, + {"1.2.3.4", false}, + } + + for _, tc := range cases { + v, err1 := NewVersion(tc.version) + if err1 != nil { + t.Fatalf("error for version %q: %s", tc.version, err1) + } + + parsed, err2 := json.Marshal(v) + if err2 != nil { + t.Fatalf("error marshaling version %q: %s", tc.version, err2) + } + result := string(parsed) + expected := fmt.Sprintf("%q", tc.version) + if result != expected && !tc.err { + t.Fatalf("Error marshaling unexpected marshaled content: result=%q expected=%q", result, expected) + } + } +} + +func TestJsonUnmarshal(t *testing.T) { + cases := []struct { + version string + err bool + }{ + {"1.2.3", false}, + {"1.2.0-x.Y.0+metadata", false}, + {"1.2.0-x.Y.0+metadata-width-hyphen", false}, + {"1.2.3-rc1-with-hyphen", false}, + {"1.2.3.4", false}, + {"1.2.0.4-x.Y.0+metadata", false}, + {"1.2.0.4-x.Y.0+metadata-width-hyphen", false}, + {"1.2.0-X-1.2.0+metadata~dist", false}, + {"1.2.3.4-rc1-with-hyphen", false}, + {"1.2.3.4", false}, + } + + for _, tc := range cases { + expected, err1 := NewVersion(tc.version) + if err1 != nil { + t.Fatalf("err: %s", err1) + } + + actual := &Version{} + err2 := json.Unmarshal([]byte(fmt.Sprintf("%q", tc.version)), actual) + if err2 != nil { + t.Fatalf("error unmarshaling version: %s", err2) + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("error unmarshaling, unexpected object content: actual=%q expected=%q", actual, expected) + } + } +} + func TestVersionString(t *testing.T) { cases := [][]string{ {"1.2.3", "1.2.3"}, @@ -596,3 +738,35 @@ func TestLessThanOrEqual(t *testing.T) { } } } + +func BenchmarkVersionString(b *testing.B) { + v, _ := NewVersion("3.4.5-rc1+meta") + _ = v.String() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = v.String() + } +} + +func BenchmarkCompareVersionV1(b *testing.B) { + v, _ := NewVersion("3.4.5") + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + v.Compare(v) + } +} + +func BenchmarkVersionCompareV2(b *testing.B) { + v, _ := NewVersion("1.2.3") + o, _ := NewVersion("v1.2.3.4") + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + v.Compare(o) + } +}