diff --git a/.travis.yml b/.travis.yml index 8c81e56..35386a9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,13 @@ language: go go: - - 1.10.x + - 1.12.x - tip -before_install: - - go get -t -v ./... +env: + - GO111MODULE=on + +install: true script: - go test -race -coverprofile=coverage.txt -covermode=atomic diff --git a/README.md b/README.md index 8939238..b516b17 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ # go.bug.st/relaxed-semver [![build status](https://api.travis-ci.org/bugst/relaxed-semver.svg?branch=master)](https://travis-ci.org/bugst/relaxed-semver) [![codecov](https://codecov.io/gh/bugst/relaxed-semver/branch/master/graph/badge.svg)](https://codecov.io/gh/bugst/relaxed-semver) - - A library for handling a superset of semantic versioning in golang. ## Documentation and examples @@ -12,9 +10,9 @@ See the godoc here: https://godoc.org/go.bug.st/relaxed-semver ## Semantic versioning specification followed in this library -This library tries to implement the semantic versioning specification [2.0.0](https://semver.org/spec/v2.0.0.html) with an exception: the numeric format `major.minor.patch` like `1.3.2` may be truncated if a number is zero, so: +This library tries to implement the semantic versioning specification [2.0.0](https://semver.org/spec/v2.0.0.html) with an exception: the numeric format `major.minor.patch` like `1.3.2` may be truncated if a number is zero, so: - - `1.2.0` or `1.2.0-beta` may be written as `1.2` or `1.2-beta` respectively + - `1.2.0` or `1.2.0-beta` may be written as `1.2` or `1.2-beta` respectively - `1.0.0` or `1.0.0-beta` may be written `1` or `1-beta` respectively - `0.0.0` may be written as the **empty string**, but `0.0.0-beta` may **not** be written as `-beta` ## Usage @@ -37,4 +35,4 @@ To parse a `RelaxedVersion` you can use the `ParseRelaxed` function. ## Json parsable -The `Version` and`RelaxedVersion` have the JSON un/marshaler implemented so they can be JSON decoded/encoded. \ No newline at end of file +The `Version` and `RelaxedVersion` have the JSON un/marshaler implemented so they can be JSON decoded/encoded. \ No newline at end of file diff --git a/constraints.go b/constraints.go new file mode 100644 index 0000000..68f928e --- /dev/null +++ b/constraints.go @@ -0,0 +1,248 @@ +// +// Copyright 2019 Cristian Maglie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// + +package semver + +import ( + "fmt" + "strings" +) + +// Constraint is a condition that a Version can match or not +type Constraint interface { + // Match returns true if the Version satisfies the condition + Match(*Version) bool + + String() string +} + +// ParseConstraint converts a string into a Constraint. The resulting Constraint +// may be converted back to string using the String() method. +// WIP: only simple constraint (like ""=1.2.0" or ">=2.0.0) are parsed for now +// a full parser will be deployed in the future +func ParseConstraint(in string) (Constraint, error) { + in = strings.TrimSpace(in) + curr := 0 + l := len(in) + if l == 0 { + return &True{}, nil + } + next := func() byte { + if curr < l { + curr++ + return in[curr-1] + } + return 0 + } + peek := func() byte { + if curr < l { + return in[curr] + } + return 0 + } + + ver := func() (*Version, error) { + start := curr + for { + n := peek() + if !isIdentifier(n) && !isVersionSeparator(n) { + if start == curr { + return nil, fmt.Errorf("invalid version") + } + return Parse(in[start:curr]) + } + curr++ + } + } + + stack := []Constraint{} + for { + switch next() { + case '=': + if v, err := ver(); err == nil { + stack = append(stack, &Equals{v}) + } else { + return nil, err + } + case '>': + if peek() == '=' { + next() + if v, err := ver(); err == nil { + stack = append(stack, &GreaterThanOrEqual{v}) + } else { + return nil, err + } + } else { + if v, err := ver(); err == nil { + stack = append(stack, &GreaterThan{v}) + } else { + return nil, err + } + } + case '<': + if peek() == '=' { + next() + if v, err := ver(); err == nil { + stack = append(stack, &LessThanOrEqual{v}) + } else { + return nil, err + } + } else { + if v, err := ver(); err == nil { + stack = append(stack, &LessThan{v}) + } else { + return nil, err + } + } + case ' ': + // ignore + default: + return nil, fmt.Errorf("unexpected char at: %s", in[curr-1:]) + case 0: + if len(stack) != 1 { + return nil, fmt.Errorf("invalid constraint: %s", in) + } + return stack[0], nil + } + } +} + +// True is the empty constraint +type True struct { +} + +// Match always return true +func (t *True) Match(v *Version) bool { + return true +} + +func (t *True) String() string { + return "" +} + +// Equals is the equality (=) constraint +type Equals struct { + Version *Version +} + +// Match returns true if v satisfies the condition +func (eq *Equals) Match(v *Version) bool { + return v.Equal(eq.Version) +} + +func (eq *Equals) String() string { + return "=" + eq.Version.String() +} + +// LessThan is the less than (<) constraint +type LessThan struct { + Version *Version +} + +// Match returns true if v satisfies the condition +func (lt *LessThan) Match(v *Version) bool { + return v.LessThan(lt.Version) +} + +func (lt *LessThan) String() string { + return "<" + lt.Version.String() +} + +// LessThanOrEqual is the "less than or equal" (<=) constraint +type LessThanOrEqual struct { + Version *Version +} + +// Match returns true if v satisfies the condition +func (lte *LessThanOrEqual) Match(v *Version) bool { + return v.LessThanOrEqual(lte.Version) +} + +func (lte *LessThanOrEqual) String() string { + return "<=" + lte.Version.String() +} + +// GreaterThan is the "greater than" (>) constraint +type GreaterThan struct { + Version *Version +} + +// Match returns true if v satisfies the condition +func (gt *GreaterThan) Match(v *Version) bool { + return v.GreaterThan(gt.Version) +} + +func (gt *GreaterThan) String() string { + return ">" + gt.Version.String() +} + +// GreaterThanOrEqual is the "greater than or equal" (>=) constraint +type GreaterThanOrEqual struct { + Version *Version +} + +// Match returns true if v satisfies the condition +func (gte *GreaterThanOrEqual) Match(v *Version) bool { + return v.GreaterThanOrEqual(gte.Version) +} + +func (gte *GreaterThanOrEqual) String() string { + return ">=" + gte.Version.String() +} + +// Or will match if ANY of the Operands Constraint will match +type Or struct { + Operands []Constraint +} + +// Match returns true if v satisfies the condition +func (or *Or) Match(v *Version) bool { + for _, op := range or.Operands { + if op.Match(v) { + return true + } + } + return false +} + +func (or *Or) String() string { + res := "(" + for i, op := range or.Operands { + if i > 0 { + res += " || " + } + res += op.String() + } + res += ")" + return res +} + +// And will match if ALL the Operands Constraint will match +type And struct { + Operands []Constraint +} + +// Match returns true if v satisfies the condition +func (and *And) Match(v *Version) bool { + for _, op := range and.Operands { + if !op.Match(v) { + return false + } + } + return true +} + +func (and *And) String() string { + res := "(" + for i, op := range and.Operands { + if i > 0 { + res += " && " + } + res += op.String() + } + res += ")" + return res +} diff --git a/constraints_test.go b/constraints_test.go new file mode 100644 index 0000000..5e44c2a --- /dev/null +++ b/constraints_test.go @@ -0,0 +1,109 @@ +// +// Copyright 2019 Cristian Maglie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// + +package semver + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConstraints(t *testing.T) { + lt := &LessThan{v("1.3.0")} + require.True(t, lt.Match(v("1.0.0"))) + require.False(t, lt.Match(v("1.3.0"))) + require.False(t, lt.Match(v("2.0.0"))) + require.Equal(t, "<1.3.0", lt.String()) + + lte := &LessThanOrEqual{v("1.3.0")} + require.True(t, lte.Match(v("1.0.0"))) + require.True(t, lte.Match(v("1.3.0"))) + require.False(t, lte.Match(v("2.0.0"))) + require.Equal(t, "<=1.3.0", lte.String()) + + eq := &Equals{v("1.3.0")} + require.False(t, eq.Match(v("1.0.0"))) + require.True(t, eq.Match(v("1.3.0"))) + require.False(t, eq.Match(v("2.0.0"))) + require.Equal(t, "=1.3.0", eq.String()) + + gte := &GreaterThanOrEqual{v("1.3.0")} + require.False(t, gte.Match(v("1.0.0"))) + require.True(t, gte.Match(v("1.3.0"))) + require.True(t, gte.Match(v("2.0.0"))) + require.Equal(t, ">=1.3.0", gte.String()) + + gt := &GreaterThan{v("1.3.0")} + require.False(t, gt.Match(v("1.0.0"))) + require.False(t, gt.Match(v("1.3.0"))) + require.True(t, gt.Match(v("2.0.0"))) + require.Equal(t, ">1.3.0", gt.String()) + + tr := &True{} + require.True(t, tr.Match(v("1.0.0"))) + require.True(t, tr.Match(v("1.3.0"))) + require.True(t, tr.Match(v("2.0.0"))) + require.Equal(t, "", tr.String()) + + gt100 := &GreaterThan{v("1.0.0")} + lte200 := &LessThanOrEqual{v("2.0.0")} + and := &And{[]Constraint{gt100, lte200}} + require.False(t, and.Match(v("0.9.0"))) + require.False(t, and.Match(v("1.0.0"))) + require.True(t, and.Match(v("1.3.0"))) + require.True(t, and.Match(v("2.0.0"))) + require.False(t, and.Match(v("2.1.0"))) + require.Equal(t, "(>1.0.0 && <=2.0.0)", and.String()) + + gt200 := &GreaterThan{v("2.0.0")} + lte100 := &LessThanOrEqual{v("1.0.0")} + or := &Or{[]Constraint{gt200, lte100}} + require.True(t, or.Match(v("0.9.0"))) + require.True(t, or.Match(v("1.0.0"))) + require.False(t, or.Match(v("1.3.0"))) + require.False(t, or.Match(v("2.0.0"))) + require.True(t, or.Match(v("2.1.0"))) + require.Equal(t, "(>2.0.0 || <=1.0.0)", or.String()) +} + +func TestConstraintsParser(t *testing.T) { + good := map[string]string{ + "": "", + "=1.3.0": "=1.3.0", + " =1.3.0 ": "=1.3.0", + "=1.3.0 ": "=1.3.0", + " =1.3.0": "=1.3.0", + ">=1.3.0": ">=1.3.0", + ">1.3.0": ">1.3.0", + "<=1.3.0": "<=1.3.0", + "<1.3.0": "<1.3.0", + } + for s, r := range good { + p, err := ParseConstraint(s) + require.NoError(t, err) + require.Equal(t, r, p.String()) + fmt.Printf("'%s' parsed as %s\n", s, p.String()) + } + bad := []string{ + "1.0.0", + "= 1.0.0", + ">= 1.0.0", + "> 1.0.0", + "<= 1.0.0", + "< 1.0.0", + ">>1.0.0", + ">1.0.0 =2.0.0", + ">1.0.0 &", + } + for _, s := range bad { + p, err := ParseConstraint(s) + require.Nil(t, p) + require.Error(t, err) + fmt.Printf("'%s' parse error: %s\n", s, err) + } +} diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..bb841e7 --- /dev/null +++ b/debug.go @@ -0,0 +1,9 @@ +// +// Copyright 2019 Cristian Maglie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// + +package semver + +var debug = func(format string, a ...interface{}) {} diff --git a/debug_test.go b/debug_test.go new file mode 100644 index 0000000..f717075 --- /dev/null +++ b/debug_test.go @@ -0,0 +1,28 @@ +// +// Copyright 2019 Cristian Maglie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// + +package semver + +import ( + "fmt" + rtdebug "runtime/debug" + "strings" +) + +func init() { + debug = func(format string, a ...interface{}) { + level := strings.Count(string(rtdebug.Stack()), "\n") + for i := 0; i < level; i++ { + fmt.Print(" ") + } + if a != nil { + fmt.Printf(format, a...) + fmt.Println() + } else { + fmt.Println(format) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d44ecc --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module go.bug.st/relaxed-semver + +go 1.12 + +require github.com/stretchr/testify v1.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4347755 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/resolver.go b/resolver.go new file mode 100644 index 0000000..d303c73 --- /dev/null +++ b/resolver.go @@ -0,0 +1,93 @@ +// +// Copyright 2019 Cristian Maglie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// + +package semver + +// Dependency represents a dependency, it must provide methods to return Name and Constraints +type Dependency interface { + GetName() string + GetConstraint() Constraint +} + +// Release represents a release, it must provide methods to return Name, Version and Dependencies +type Release interface { + GetName() string + GetVersion() *Version + GetDependencies() []Dependency +} + +func match(r Release, dep Dependency) bool { + return r.GetName() == dep.GetName() && dep.GetConstraint().Match(r.GetVersion()) +} + +// Releases is a list of Release +type Releases []Release + +// FilterBy return a subset of the Releases matching the provided Dependency +func (set Releases) FilterBy(dep Dependency) Releases { + res := []Release{} + for _, r := range set { + if match(r, dep) { + res = append(res, r) + } + } + return res +} + +// Archive contains all Releases set to consider for dependency resolution +type Archive struct { + Releases map[string]Releases +} + +// Resolve will try to depp-resolve dependencies from the Release passed as +// arguent using a backtracking algorithm. +func (ar *Archive) Resolve(release Release) []Release { + solution := map[string]Release{release.GetName(): release} + depsToProcess := release.GetDependencies() + return ar.resolve(solution, depsToProcess) +} + +func (ar *Archive) resolve(solution map[string]Release, depsToProcess []Dependency) []Release { + debug("deps to process: %s", depsToProcess) + if len(depsToProcess) == 0 { + debug("All dependencies have been resolved.") + res := []Release{} + for _, v := range solution { + res = append(res, v) + } + return res + } + + // Pick the first dependency in the deps to process + dep := depsToProcess[0] + depName := dep.GetName() + debug("Considering next dep: %s", dep) + + // If a release is already picked in the solution check if it match the dep + if existingRelease, has := solution[depName]; has { + if match(existingRelease, dep) { + debug("%s already in solution and matching", existingRelease) + return ar.resolve(solution, depsToProcess[1:]) + } + debug("%s already in solution do not match... rollingback", existingRelease) + return nil + } + + // Otherwise start backtracking the dependency + releases := ar.Releases[dep.GetName()].FilterBy(dep) + debug("releases matching criteria: %s", releases) + for _, release := range releases { + debug("try with %s %s", release, release.GetDependencies()) + solution[depName] = release + res := ar.resolve(solution, append(depsToProcess[1:], release.GetDependencies()...)) + if res != nil { + return res + } + debug("%s did not work...", release) + delete(solution, depName) + } + return nil +} diff --git a/resolver_test.go b/resolver_test.go new file mode 100644 index 0000000..91a9f67 --- /dev/null +++ b/resolver_test.go @@ -0,0 +1,130 @@ +// +// Copyright 2019 Cristian Maglie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// + +package semver + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type customDep struct { + name string + cond Constraint +} + +func (c *customDep) GetName() string { + return c.name +} + +func (c *customDep) GetConstraint() Constraint { + return c.cond +} + +func (c *customDep) String() string { + return c.name + c.cond.String() +} + +type customRel struct { + name string + vers *Version + deps []Dependency +} + +func (r *customRel) GetName() string { + return r.name +} + +func (r *customRel) GetVersion() *Version { + return r.vers +} + +func (r *customRel) GetDependencies() []Dependency { + return r.deps +} + +func (r *customRel) String() string { + return r.name + "@" + r.vers.String() +} + +func d(dep string) Dependency { + name := dep[0:1] + cond, err := ParseConstraint(dep[1:]) + if err != nil { + panic("invalid operator in dep: " + dep + " (" + err.Error() + ")") + } + return &customDep{name: name, cond: cond} +} + +func deps(deps ...string) []Dependency { + res := []Dependency{} + for _, dep := range deps { + res = append(res, d(dep)) + } + return res +} + +func rel(name, ver string, deps []Dependency) Release { + return &customRel{name: name, vers: v(ver), deps: deps} +} + +func TestResolver(t *testing.T) { + b131 := rel("B", "1.3.1", deps("C<2.0.0")) + b130 := rel("B", "1.3.0", deps()) + b121 := rel("B", "1.2.1", deps()) + b120 := rel("B", "1.2.0", deps()) + b111 := rel("B", "1.1.1", deps()) + b110 := rel("B", "1.1.0", deps()) + b100 := rel("B", "1.0.0", deps()) + c200 := rel("C", "2.0.0", deps()) + c120 := rel("C", "1.2.0", deps()) + c111 := rel("C", "1.1.1", deps("B=1.1.1")) + c110 := rel("C", "1.1.0", deps()) + c102 := rel("C", "1.0.2", deps()) + c101 := rel("C", "1.0.1", deps()) + c100 := rel("C", "1.0.0", deps()) + c021 := rel("C", "0.2.1", deps()) + c020 := rel("C", "0.2.0", deps()) + c010 := rel("C", "0.1.0", deps()) + arch := &Archive{ + Releases: map[string]Releases{ + "B": Releases{b131, b130, b121, b120, b111, b110, b100}, + "C": Releases{c200, c120, c111, c110, c102, c101, c100, c021, c020, c010}, + }, + } + + a100 := rel("A", "1.0.0", deps("B>=1.2.0", "C>=2.0.0")) + a110 := rel("A", "1.1.0", deps("B=1.2.0", "C>=2.0.0")) + a111 := rel("A", "1.1.1", deps("B", "C=1.1.1")) + a120 := rel("A", "1.2.0", deps("B=1.2.0", "C>2.0.0")) + + r1 := arch.Resolve(a100) + require.Len(t, r1, 3) + require.Contains(t, r1, a100) + require.Contains(t, r1, b130) + require.Contains(t, r1, c200) + fmt.Println(r1) + + r2 := arch.Resolve(a110) + require.Len(t, r2, 3) + require.Contains(t, r2, a110) + require.Contains(t, r2, b120) + require.Contains(t, r2, c200) + fmt.Println(r2) + + r3 := arch.Resolve(a111) + require.Len(t, r3, 3) + require.Contains(t, r3, a111) + require.Contains(t, r3, b111) + require.Contains(t, r3, c111) + fmt.Println(r3) + + r4 := arch.Resolve(a120) + require.Nil(t, r4) + fmt.Println(r4) +} diff --git a/version_test.go b/version_test.go index f977f34..f9b5cc0 100644 --- a/version_test.go +++ b/version_test.go @@ -13,6 +13,10 @@ import ( "github.com/stretchr/testify/require" ) +func v(vers string) *Version { + return MustParse(vers) +} + func TestVersionComparator(t *testing.T) { sign := map[int]string{1: ">", 0: "=", -1: "<"} ascending := func(list ...*Version) { @@ -39,8 +43,8 @@ func TestVersionComparator(t *testing.T) { } } equal := func(list ...*Version) { - for _, a := range list { - for _, b := range list { + for i, a := range list[:len(list)-1] { + for _, b := range list[i+1:] { comp := a.CompareTo(b) fmt.Printf("%s %s %s\n", a, sign[comp], b) require.Equal(t, comp, 0) @@ -72,6 +76,8 @@ func TestVersionComparator(t *testing.T) { MustParse("1.0.0"), MustParse("1.0.1"), MustParse("1.1.1"), + MustParse("1.6.22"), + MustParse("1.8.1"), MustParse("2.1.1"), ) equal(