From 89fc41d60f28b0fe092d16b4a8f6b7f5f502adbd Mon Sep 17 00:00:00 2001 From: Iteron-dev Date: Sun, 1 Jun 2025 15:17:58 +0200 Subject: [PATCH 1/4] Feat: Add Intersection, IsSubset and MustParseConstraint functions --- constraints.go | 8 ++ intersection.go | 267 +++++++++++++++++++++++++++++++++++++++++++ intersection_test.go | 223 ++++++++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 intersection.go create mode 100644 intersection_test.go diff --git a/constraints.go b/constraints.go index 8461c7e..7423d65 100644 --- a/constraints.go +++ b/constraints.go @@ -52,6 +52,14 @@ func NewConstraint(c string) (*Constraints, error) { return o, nil } +func MustParseConstraint(c string) *Constraints { + sc, err := NewConstraint(c) + if err != nil { + panic(err) + } + return sc +} + // Check tests if a version satisfies the constraints. func (cs Constraints) Check(v *Version) bool { // TODO(mattfarina): For v4 of this library consolidate the Check and Validate diff --git a/intersection.go b/intersection.go new file mode 100644 index 0000000..9ae45e6 --- /dev/null +++ b/intersection.go @@ -0,0 +1,267 @@ +package semver + +import ( + "cmp" + "slices" + "strings" +) + +// Intersection returns a Constraints struct satisfied by all versions that satisfy a and b (a ∩ b). +// Returns nil if either input is nil. +func Intersection(a, b *Constraints) *Constraints { + if a == nil || b == nil { + return nil + } + + ca, cb := canonicalise(a), canonicalise(b) + var out [][]*constraint + for _, ga := range ca.constraints { + for _, gb := range cb.constraints { + g := intersect(ga, gb) + out = append(out, g) + + } + } + if len(out) == 0 { + return &Constraints{} + } + return canonicalise(&Constraints{constraints: out}) +} + +// IsSubset returns true if every version satisfying sub also satisfies sup (sub ⊆ sup). +// Returns false if either input is nil. +func IsSubset(sub, sup *Constraints) bool { + return sub != nil && sup != nil && + Intersection(sub, sup).String() == canonicalise(sub).String() +} + +func intersect(a, b []*constraint) []*constraint { + ea, ra := splitExact(a) + eb, rb := splitExact(b) + + switch { + case len(ra) == 0 && len(rb) == 0: + return exactIntersection(ea, eb) + case len(ra) == 0: + return filterExact(ea, b) + case len(rb) == 0: + return filterExact(eb, a) + default: + return simplify(append(append([]*constraint{}, a...), b...)) + } +} + +func splitExact(cs []*constraint) (exact, ranges []*constraint) { + for _, c := range cs { + if c.origfunc == "" || c.origfunc == "=" { + exact = append(exact, c) + } else { + ranges = append(ranges, c) + } + } + return exact, ranges +} + +func exactIntersection(a, b []*constraint) (res []*constraint) { + for _, ea := range a { + for _, eb := range b { + if ea.con.Equal(eb.con) { + res = append(res, ea) + } + } + } + return res +} + +func filterExact(exact, cs []*constraint) (res []*constraint) { + for _, e := range exact { + if satisfiesAll(e.con, cs) { + res = append(res, e) + } + } + return res +} + +func satisfiesAll(v *Version, cs []*constraint) bool { + for _, c := range cs { + compare := v.Compare(c.con) + switch c.origfunc { + case ">": + if compare <= 0 { + return false + } + case ">=": + if compare < 0 { + return false + } + case "<": + if compare >= 0 { + return false + } + case "<=": + if compare > 0 { + return false + } + } + } + return true +} + +func canonicalise(c *Constraints) *Constraints { + if c == nil { + return nil + } + + seen := make(map[string]struct{}) + var groups [][]*constraint + for _, g := range c.constraints { + clean := simplify(expand(g)) + if isValid(clean) { + k := groupKey(clean) + _, ok := seen[k] + if !ok { + seen[k] = struct{}{} + groups = append(groups, clean) + } + } + } + slices.SortFunc(groups, func(a, b []*constraint) int { + return cmp.Compare(groupKey(a), groupKey(b)) + }) + + return &Constraints{constraints: groups} +} + +func expand(cs []*constraint) (res []*constraint) { + for _, c := range cs { + res = append(res, expandConstraint(c)...) + } + return res +} + +func expandConstraint(c *constraint) []*constraint { + switch c.origfunc { + case "^": + return createRange(c, func() Version { + if c.con.Major() > 0 { + return c.con.IncMajor() + } + return c.con.IncMinor() + }) + case "~", "~>": + return createRange(c, func() Version { + if c.minorDirty { + return c.con.IncMajor() + } + return c.con.IncMinor() + }) + case "", "=": + if c.dirty { + return expandWildcard(c) + } + } + + return []*constraint{c} +} + +func createRange(c *constraint, upper func() Version) []*constraint { + return []*constraint{clone(c, ">="), upperConstraint(upper())} +} + +func expandWildcard(c *constraint) []*constraint { + lo := clone(c, ">=") + var hi Version + switch { + case c.minorDirty: + hi = c.con.IncMajor() + case c.patchDirty: + hi = c.con.IncMinor() + default: + return []*constraint{lo} + } + + return []*constraint{lo, upperConstraint(hi)} +} + +func simplify(cs []*constraint) (res []*constraint) { + if len(cs) <= 1 { + return cs + } + lo, hi := bounds(cs) + if lo != nil { + res = append(res, lo) + } + if hi != nil { + res = append(res, hi) + } + + return res +} + +func better(cur, cand *constraint, dir int) bool { + if cand == nil { + return false + } + if cur == nil { + return true + } + diff := cand.con.Compare(cur.con) + if diff != 0 { + return diff*dir > 0 + } + if dir > 0 { + return cur.origfunc == ">=" && cand.origfunc == ">" + } + + return cur.origfunc == "<=" && cand.origfunc == "<" +} + +func clone(c *constraint, op string) *constraint { + return &constraint{con: c.con, orig: c.con.String(), origfunc: op} +} + +func upperConstraint(v Version) *constraint { + return &constraint{con: &v, orig: v.String(), origfunc: "<"} +} + +func groupKey(cs []*constraint) string { + var sb strings.Builder + for _, c := range cs { + sb.WriteString(c.string()) + sb.WriteByte(' ') + } + return sb.String() +} + +func isValid(cs []*constraint) bool { + if len(cs) == 0 { + return false + } + + lo, hi := bounds(cs) + if lo == nil || hi == nil { + return true + } + + compare := lo.con.Compare(hi.con) + if compare > 0 || (compare == 0 && (lo.origfunc != ">=" || hi.origfunc != "<=")) { + return false + } + return true +} + +func bounds(cs []*constraint) (lo, hi *constraint) { + for _, c := range cs { + switch c.origfunc { + case ">", ">=": + if better(lo, c, 1) { + lo = c + } + case "<", "<=": + if better(hi, c, -1) { + hi = c + } + } + } + return lo, hi +} diff --git a/intersection_test.go b/intersection_test.go new file mode 100644 index 0000000..044f682 --- /dev/null +++ b/intersection_test.go @@ -0,0 +1,223 @@ +package semver + +import ( + "strconv" + "testing" +) + +func TestIntersection_NilSafety(t *testing.T) { + c := MustParseConstraint(">=0.0.0") + if Intersection(nil, c) != nil { + t.Fatal("Intersection(nil, c) should return nil") + } + if Intersection(c, nil) != nil { + t.Fatal("Intersection(c, nil) should return nil") + } + if Intersection(nil, nil) != nil { + t.Fatal("Intersection(nil, nil) should return nil") + } +} + +func TestIntersection_MixedOperators2(t *testing.T) { + cases := []struct { + a, b, want string + }{ + {"^1", ">=1.4.0", ">=1.4.0 <2.0.0"}, + {"~1.2", "<1.2.5", ">=1.2.0 <1.2.5"}, + {"^0.2.3", ">=0.2.4", ">=0.2.4 <0.3.0"}, + {"~1", "<1.5.0", ">=1.0.0 <1.5.0"}, + {">=1.0.0 <2.0.0", ">=1.5.0 <3.0.0", ">=1.5.0 <2.0.0"}, + {"~1.2.0", ">=1.2.3 <1.3.0", ">=1.2.3 <1.3.0"}, + {"^1.2.0", ">=1.5.0 <2.0.0", ">=1.5.0 <2.0.0"}, + {"1.0.0 || 2.0.0", ">=1.0.0 <=2.0.0", "1.0.0 || 2.0.0"}, + {"^1.0.0 || ~2.1.0", ">=1.5.0 <2.2.0", ">=1.5.0 <2.0.0 || >=2.1.0 <2.2.0"}, + {">=1.0.0 <2.0.0", ">=3.0.0 <4.0.0", ""}, + {"1.2.3 || 1.2.4", ">=1.2.3 <=1.2.5", "1.2.3 || 1.2.4"}, + {"^2.0.0 || ~1.5.0", ">=1.5.2 <2.1.0", ">=1.5.2 <1.6.0 || >=2.0.0 <2.1.0"}, + {">=1.0.0 <2.0.0 || >=3.0.0 <4.0.0", ">=1.5.0 <3.5.0", ">=1.5.0 <2.0.0 || >=3.0.0 <3.5.0"}, + {">=1.0.0-alpha <1.0.0", ">=1.0.0-beta <1.0.0-gamma", ">=1.0.0-beta <1.0.0-gamma"}, + {">=1.0.0", ">=1.0.0", ">=1.0.0"}, + {">=1.0.0-alpha.1 <1.0.0-beta", ">=1.0.0-alpha.2 <1.0.0-alpha.10", ">=1.0.0-alpha.2 <1.0.0-alpha.10"}, + {">=1.0.0-1 <1.0.0-10", ">=1.0.0-2 <1.0.0-5", ">=1.0.0-2 <1.0.0-5"}, + {">=1.0.0-alpha+build1", ">=1.0.0-alpha+build2", ">=1.0.0-alpha+build1"}, + {">=1.0.0-alpha <2.0.0", ">=1.0.0 <1.5.0", ">=1.0.0 <1.5.0"}, + {">=1.0.0 <=2.0.0", ">2.0.0 <3.0.0", ""}, + {">=1.0.0 <=2.0.0", ">=2.0.0 <3.0.0", ">=2.0.0 <=2.0.0"}, + {">=0.0.0 <0.1.0", ">=0.0.1 <1.0.0", ">=0.0.1 <0.1.0"}, + {">=999999.999999.999999", ">=1000000.0.0 <2000000.0.0", ">=1000000.0.0 <2000000.0.0"}, + {">1.0.0 <1.0.1", ">=1.0.0 <=1.0.0", ""}, + {"1.0.0 || 3.0.0 || 5.0.0", "2.0.0 || 4.0.0 || 6.0.0", ""}, + {">=1.0.0 <2.0.0 || >=4.0.0 <5.0.0", ">=1.5.0 <3.0.0 || >=4.5.0 <6.0.0", ">=1.5.0 <2.0.0 || >=4.5.0 <5.0.0"}, + {">=4.0.0 <5.0.0 || >=1.0.0 <2.0.0", ">=4.5.0 <6.0.0 || >=1.5.0 <3.0.0", ">=1.5.0 <2.0.0 || >=4.5.0 <5.0.0"}, + {"1.0.0 || 1.1.0 || 1.2.0 || 1.3.0", ">=1.1.0 <=1.2.0", "1.1.0 || 1.2.0"}, + {"1.0.0 || >=2.0.0 <3.0.0", ">=0.9.0 <=1.0.0 || 2.5.0", "1.0.0 || 2.5.0"}, + {">=1.0.0 >=1.2.0", ">=1.1.0", ">=1.2.0"}, + {"<2.0.0 <1.8.0", "<1.9.0", "<1.8.0"}, + {">1.0.0 >=1.0.0", "<=2.0.0 <2.0.0", ">1.0.0 <2.0.0"}, + {">=2.0.0", "<1.0.0", ""}, + {"1.2.3 || 1.4.0", ">=1.0.0 <1.3.0", "1.2.3"}, + {"1.2.3", "=1.2.3", "1.2.3"}, + {"1.2.3", "=1.24", ""}, + {"1", ">=1.4.0", ">=1.4.0 <2.0.0"}, + // * + {">=1.0.0 >=1.2.0", "*", ">=1.2.0"}, + {"<2.0.0 <1.8.0", "*", ">=0.0.0 <1.8.0"}, + {"1.x", "*", ">=1.0.0 <2.0.0"}, + {"1.x", "<1.5.0", ">=1.0.0 <1.5.0"}, + {">=1.2.0", "*", ">=1.2.0"}, + {"<2.0.0 <=1.8.0", "*", ">=0.0.0 <=1.8.0"}, + {">1.0.0 >=1.0.0", "*", ">1.0.0"}, + {">=1.0.0 >=1.2.0 <=2.0.0 <2.5.0", "*", ">=1.2.0 <=2.0.0"}, + {"1.2.x", ">=1.2.3", ">=1.2.3 <1.3.0"}, + {"1.2.x", "<1.2.1", ">=1.2.0 <1.2.1"}, + {"0.x.x", "<0.3.0", ">=0.0.0 <0.3.0"}, + {"1.x", ">=1.2.0 <1.4.0", ">=1.2.0 <1.4.0"}, + {"1.2.x", ">=1.2.3 <1.2.8", ">=1.2.3 <1.2.8"}, + {">=1.0.0-alpha <1.0.0-beta", ">=1.0.0-beta <1.0.0-rc", ""}, + {"=1.2.3", ">1.2.3", ""}, + } + + for i, tc := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + got := Intersection(MustParseConstraint(tc.a), MustParseConstraint(tc.b)).String() + if got != tc.want { + t.Errorf("Intersection(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestIsSubset_NilSafety(t *testing.T) { + c := MustParseConstraint(">=1.2.3 <4") + if IsSubset(nil, c) { + t.Fatal("IsSubset(nil, c) should not be false") + } + if IsSubset(c, nil) { + t.Fatal("IsSubset(nil, c) should not be false") + } + if IsSubset(nil, nil) { + t.Fatal("IsSubset(nil, nil) should not be false") + } +} + +func TestIsSubset(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"~8", ">=8 <=17", true}, + {"~1.2.x", "^1.2.x", true}, + {"~1.2.3", "~>1.2.3", true}, + {"~>2.0", "^2", true}, + {"~>1.2.x", "~1.2.x", true}, + {"~1.x", "^1", true}, + {"~1.x", "^1.1", false}, + {">=1.4.0", "^1", false}, + {"^1", ">=1.4.0", false}, + {">1 <2", ">=1 <3", true}, + {">1 <=2", ">=0 <3", true}, + {">=1.5.0 <2.0.0", ">=1.0.0 <2.5.0", true}, + {">=1.0.0 <2.0.0 || >=3.0.0 <4.0.0", ">=0.5.0 <5.0.0", true}, + {">=1.0.0 <2.0.0", ">=0.5.0 <3.0.0", true}, + {">=1.0.0 <2.0.0 || >=4.0.0 <5.0.0", ">=1.0.0 <3.0.0", false}, + {">=1.0.0 <3.0.0", ">=1.0.0 <2.0.0 || >=4.0.0 <5.0.0", false}, + {"1.4.0", ">=1.5.0 <2.0.0 || >=3.0.0 <3.5.0", false}, + {"1.5.0", ">=1.5.0 <2.0.0 || >=3.0.0 <3.5.0", true}, + {"2.5.0", ">=1.5.0 <2.0.0 || >=3.0.0 <3.5.0", false}, + {"3.2.0", ">=1.5.0 <2.0.0 || >=3.0.0 <3.5.0", true}, + {"3.6.0", ">=1.5.0 <2.0.0 || >=3.0.0 <3.5.0", false}, + {">=3.1.0 <3.5.0 || >=1.7.0 <1.9.0", ">=3.0.0 <3.5.0 || >=1.5.0 <2.0.0", true}, + {">1 <2", ">2 <3", false}, + {">1 <2", ">1.5 <2.5", false}, + {">=1.0.0 <=2.0.0", ">=1.0.0 <=2.0.0", true}, + {">1", ">=1", true}, + {"<2", "<=2", true}, + {">1 <=2", ">1 <2.5", true}, + {">=1.0.0", ">=0.0.0", true}, + {">=1.0.0", ">=1.0.0 <2.0.0", false}, + {">=1.2.3 <4", ">=1.2.3 <4", true}, + {"^1", "^1", true}, + {"^1.2.3", "^1.2.3", true}, + {"~1", "~1", true}, + {"~1.2", "~1.2", true}, + {"^1.2.0", "^1", true}, + {"~1.2", "~1", true}, + {"^1", "^1.2.0", false}, + {"~1", "~2", false}, + {"^1", "^2", false}, + {"^0.2.3", "^0.2.4", false}, + {"~1.2", ">=1.2.5 <1.3.0", false}, + {"^1.2.3", ">=1.2.3 <2.0.0", true}, + {"^1.2.3", ">=1.3.0 <2.0.0", false}, + {"^0.2", ">=0.2.0 <0.3.0", true}, + {"^0.2", ">=0.2.5 <0.3.0", false}, + {"~1", ">=1.0.0 <2.0.0", true}, + {"~1", ">=1.5.0 <2.0.0", false}, + {"^2", ">=2.3.0 <3.0.0", false}, + {"~1.2", ">=1.2.0 <1.3.0", true}, + {"~1.2", ">=1.0.0 <2.0.0", true}, + {"~1", ">=1.4.0 <2.0.0", false}, + {"^1", "<2.0.0", true}, + {"^1", ">=1.4.0 <2.0.0", false}, + {">=1.2.0 <1.3.0", ">=1.0.0 <2.0.0", true}, + {"~1.2.0", ">=1.0.0 <2.0.0", true}, + {"^1.2.0", ">=1.0.0 <2.0.0", true}, + {">=1.0.0 <3.0.0", ">=1.0.0 <2.0.0", false}, + {">=0.5.0 <2.0.0", ">=1.0.0 <2.0.0", false}, + {"1.2.3", ">=1.0.0 <=2.0.0", true}, + {"1.2.3 || 1.2.4", ">=1.2.0 <1.3.0", true}, + {"~1.2.0 || ^1.5.0", ">=1.0.0 <2.0.0", true}, + {"~1.2.0 || ^2.0.0", ">=1.0.0 <2.0.0", false}, + {">=1.2.0 <1.3.0", "~1.2.0", true}, + {">=1.0.0-alpha <1.0.0-beta", ">=1.0.0-alpha <1.0.0", true}, + {">=1.5.0 <2.5.0", ">=1.0.0 <2.0.0", false}, + {">=3.0.0 <4.0.0", ">=1.0.0 <2.0.0", false}, + {">=1.0.0 <2.0.0", ">=1.0.0 <2.0.0", true}, + {"1.0.0 || 2.0.0 || 3.0.0", ">=1.0.0 <=2.0.0", false}, + {"1.0.0 || 1.5.0 || 2.0.0", ">=1.0.0 <=2.0.0", true}, + {"1.5.0", ">=2.0.0 <1.0.0", false}, + {">=1.0.0-alpha <1.0.0-beta", ">=1.0.0 <2.0.0", false}, + {"1.0.0+build1", "1.0.0+build2", true}, + {"1.0.0 || 3.0.0", ">=0.9.0 <=1.1.0", false}, + {"^1.2.3", ">=1.0.0 <2.0.0", true}, + {">=1.2.4 <1.3.0", "~1.2.0", true}, + {">=1.0.0-beta.1 <1.0.0", ">=1.0.0-alpha <1.0.0", true}, + {"1.2.3 || 1.2.4 || 1.2.5", "~1.2.0", true}, + {"1.2.3", "=1.24", false}, + {">=1.2.0 >=1.0.0", ">=1.1.0", true}, + {">=1.1.0", ">=1.2.0 >=1.0.0", false}, + {"<1.8.0 <2.0.0", "<2.0.0", true}, + {"<2.0.0", "<1.8.0 <2.0.0", false}, + {">=1.2.0 <1.5.0 >=1.0.0", ">=1.1.0 <2.0.0", true}, + {">=1.2.0 <1.5.0", ">=1.2.0 <=1.4.0", false}, + {">=1.0.0 <=2.0.0 >=1.0.0", ">=1.0.0 <=2.0.0", true}, + {"<=2.0.0 <2.0.0", "<=2.0.0", true}, + {"<=2.0.0", "<2.0.0 <=2.0.0", false}, + // x + {"1.x", "^1", true}, + {"^1", "1.x", true}, + {"1.2.x", "1.x", true}, + {"1.x", "1.2.x", false}, + {"1.2.x", "x.x.x", true}, + {"0.2.x", "0.x.x", true}, + {"^0.2.4", "0.x.x", true}, + {"~0.2.4", "0.x.x", true}, + {"=0.2.4", "=0.x.x", true}, + // * + {">=3.0.0 <2.0.0", "*", true}, + {"*", "*", true}, + {"*", "<2.0.0", false}, + {"0.x", "<1.0.0", true}, + {"0.x", ">=0.1.0 <0.5.0", false}, + } + + for i, tc := range cases { + t.Run(strconv.Itoa(i), + func(t *testing.T) { + if got := IsSubset(MustParseConstraint(tc.a), MustParseConstraint(tc.b)); got != tc.want { + t.Errorf("IsSubset(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + + } +} From 7a11095cd536ef0103f89ed362e51dd48e4700e8 Mon Sep 17 00:00:00 2001 From: Iteron-dev Date: Mon, 23 Jun 2025 15:45:03 +0200 Subject: [PATCH 2/4] Fix: Correctly handle intersection of ">=1 <=2" and "~2" --- intersection.go | 10 ++++++++++ intersection_test.go | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/intersection.go b/intersection.go index 9ae45e6..ac37502 100644 --- a/intersection.go +++ b/intersection.go @@ -159,6 +159,16 @@ func expandConstraint(c *constraint) []*constraint { if c.dirty { return expandWildcard(c) } + case "<=": + if c.dirty { + var hi Version + if c.minorDirty { + hi = c.con.IncMajor() + } else { + hi = c.con.IncMinor() + } + return []*constraint{upperConstraint(hi)} + } } return []*constraint{c} diff --git a/intersection_test.go b/intersection_test.go index 044f682..ef30987 100644 --- a/intersection_test.go +++ b/intersection_test.go @@ -18,7 +18,7 @@ func TestIntersection_NilSafety(t *testing.T) { } } -func TestIntersection_MixedOperators2(t *testing.T) { +func TestIntersection(t *testing.T) { cases := []struct { a, b, want string }{ @@ -75,6 +75,7 @@ func TestIntersection_MixedOperators2(t *testing.T) { {"1.2.x", ">=1.2.3 <1.2.8", ">=1.2.3 <1.2.8"}, {">=1.0.0-alpha <1.0.0-beta", ">=1.0.0-beta <1.0.0-rc", ""}, {"=1.2.3", ">1.2.3", ""}, + {">=1 <=2", "~2", ">=2.0.0 <3.0.0"}, } for i, tc := range cases { @@ -132,7 +133,7 @@ func TestIsSubset(t *testing.T) { {">=1.0.0 <=2.0.0", ">=1.0.0 <=2.0.0", true}, {">1", ">=1", true}, {"<2", "<=2", true}, - {">1 <=2", ">1 <2.5", true}, + {">1 <=2", ">1 <2.5", false}, {">=1.0.0", ">=0.0.0", true}, {">=1.0.0", ">=1.0.0 <2.0.0", false}, {">=1.2.3 <4", ">=1.2.3 <4", true}, @@ -209,6 +210,7 @@ func TestIsSubset(t *testing.T) { {"*", "<2.0.0", false}, {"0.x", "<1.0.0", true}, {"0.x", ">=0.1.0 <0.5.0", false}, + {"~2", ">=1 <=2", true}, } for i, tc := range cases { From d84eb5cdf3ec5ba21f6be4b6205874b4d2c40bd6 Mon Sep 17 00:00:00 2001 From: Iteron-dev Date: Mon, 23 Jun 2025 19:01:06 +0200 Subject: [PATCH 3/4] Fix: Handle pre-releases correctly --- intersection.go | 5 +++++ intersection_test.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/intersection.go b/intersection.go index ac37502..2280981 100644 --- a/intersection.go +++ b/intersection.go @@ -84,6 +84,11 @@ func filterExact(exact, cs []*constraint) (res []*constraint) { func satisfiesAll(v *Version, cs []*constraint) bool { for _, c := range cs { + // issue 21 + if v.Prerelease() != "" && c.con.Prerelease() == "" { + return false + } + compare := v.Compare(c.con) switch c.origfunc { case ">": diff --git a/intersection_test.go b/intersection_test.go index ef30987..d5c6ef4 100644 --- a/intersection_test.go +++ b/intersection_test.go @@ -211,6 +211,11 @@ func TestIsSubset(t *testing.T) { {"0.x", "<1.0.0", true}, {"0.x", ">=0.1.0 <0.5.0", false}, {"~2", ">=1 <=2", true}, + + // issue 21 + {"1.0.6-1", ">=1.0.3-0 <1.0.6", false}, + {"1.0.6-1", ">=1.0.3-0 <1.0.7", false}, + {"1.0.6-1", ">=1.0.3-0 <=1.0.6", false}, } for i, tc := range cases { From 58c58c892662a01afb63b5736490f1f9a512bef9 Mon Sep 17 00:00:00 2001 From: Iteron-dev Date: Wed, 9 Jul 2025 19:39:04 +0200 Subject: [PATCH 4/4] Feat: Update due to IncludePrerelease feature (c3747513) --- intersection.go | 37 +++++++---- intersection_test.go | 146 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 16 deletions(-) diff --git a/intersection.go b/intersection.go index 2280981..e374986 100644 --- a/intersection.go +++ b/intersection.go @@ -13,19 +13,26 @@ func Intersection(a, b *Constraints) *Constraints { return nil } + // We include prereleases if any of the constraints has IncludePrerelease=true. + includePre := a.IncludePrerelease || b.IncludePrerelease + ca, cb := canonicalise(a), canonicalise(b) var out [][]*constraint for _, ga := range ca.constraints { for _, gb := range cb.constraints { - g := intersect(ga, gb) + g := intersect(ga, gb, includePre) out = append(out, g) - } } + if len(out) == 0 { return &Constraints{} } - return canonicalise(&Constraints{constraints: out}) + + return &Constraints{ + constraints: canonicalise(&Constraints{constraints: out}).constraints, + IncludePrerelease: includePre, + } } // IsSubset returns true if every version satisfying sub also satisfies sup (sub ⊆ sup). @@ -35,7 +42,7 @@ func IsSubset(sub, sup *Constraints) bool { Intersection(sub, sup).String() == canonicalise(sub).String() } -func intersect(a, b []*constraint) []*constraint { +func intersect(a, b []*constraint, incPre bool) []*constraint { ea, ra := splitExact(a) eb, rb := splitExact(b) @@ -43,9 +50,9 @@ func intersect(a, b []*constraint) []*constraint { case len(ra) == 0 && len(rb) == 0: return exactIntersection(ea, eb) case len(ra) == 0: - return filterExact(ea, b) + return filterExact(ea, b, incPre) case len(rb) == 0: - return filterExact(eb, a) + return filterExact(eb, a, incPre) default: return simplify(append(append([]*constraint{}, a...), b...)) } @@ -73,19 +80,27 @@ func exactIntersection(a, b []*constraint) (res []*constraint) { return res } -func filterExact(exact, cs []*constraint) (res []*constraint) { +func filterExact(exact, cs []*constraint, incPre bool) (res []*constraint) { for _, e := range exact { - if satisfiesAll(e.con, cs) { + if satisfiesAll(e.con, cs, incPre) { res = append(res, e) } } return res } -func satisfiesAll(v *Version, cs []*constraint) bool { +func satisfiesAll(v *Version, cs []*constraint, incPre bool) bool { + if !incPre { + for _, c := range cs { + if c.con.Prerelease() != "" { + incPre = true + break + } + } + } + for _, c := range cs { - // issue 21 - if v.Prerelease() != "" && c.con.Prerelease() == "" { + if v.Prerelease() != "" && !incPre { return false } diff --git a/intersection_test.go b/intersection_test.go index d5c6ef4..2f447de 100644 --- a/intersection_test.go +++ b/intersection_test.go @@ -1,6 +1,7 @@ package semver import ( + "fmt" "strconv" "testing" ) @@ -76,6 +77,44 @@ func TestIntersection(t *testing.T) { {">=1.0.0-alpha <1.0.0-beta", ">=1.0.0-beta <1.0.0-rc", ""}, {"=1.2.3", ">1.2.3", ""}, {">=1 <=2", "~2", ">=2.0.0 <3.0.0"}, + {">=1.1.1-1", ">=1.1.1", ">=1.1.1"}, + {">=1.1.1-1", ">=1.1.1 <1.2.1-1", ">=1.1.1 <1.2.1-1"}, + + {"1.0.6-1", ">=1.0.3-0 <1.0.6", "1.0.6-1"}, + } + + for i, tc := range cases { + t.Run(fmt.Sprint("WithoutIncludePrerelease ", strconv.Itoa(i)), func(t *testing.T) { + got := Intersection(MustParseConstraint(tc.a), MustParseConstraint(tc.b)).String() + if got != tc.want { + t.Errorf("Intersection(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want) + } + }) + t.Run(fmt.Sprint("IncludePrerelease ", strconv.Itoa(i)), func(t *testing.T) { + a := MustParseConstraint(tc.a) + b := MustParseConstraint(tc.b) + a.IncludePrerelease = true + b.IncludePrerelease = true + got := Intersection(a, b).String() + if got != tc.want { + t.Errorf("Intersection(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestIntersectionWithoutIncludePrerelease(t *testing.T) { + cases := []struct { + a, b, want string + }{ + {">=1.1", "4.1.0-beta", ""}, + {">1.1", "4.1.0-beta", ""}, + {"<=1.1", "0.1.0-alpha", ""}, + {"<1.1", "0.1.0-alpha", ""}, + {"^1.x", "1.1.1-beta1", ""}, + {"~1.1", "1.1.1-alpha", ""}, + {"*", "1.2.3-alpha", ""}, + {"= 2.0", "2.0.1-beta", ""}, } for i, tc := range cases { @@ -88,6 +127,34 @@ func TestIntersection(t *testing.T) { } } +func TestIntersectionIncludePrerelease(t *testing.T) { + cases := []struct { + a, b, want string + }{ + {">=1.1", "4.1.0-beta", "4.1.0-beta"}, + {">1.1", "4.1.0-beta", "4.1.0-beta"}, + {"<=1.1", "0.1.0-alpha", "0.1.0-alpha"}, + {"<1.1", "0.1.0-alpha", "0.1.0-alpha"}, + {"^1.x", "1.1.1-beta1", "1.1.1-beta1"}, + {"~1.1", "1.1.1-alpha", "1.1.1-alpha"}, + {"*", "1.2.3-alpha", "1.2.3-alpha"}, + {"= 2.0", "2.0.1-beta", "2.0.1-beta"}, + } + + for i, tc := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + a := MustParseConstraint(tc.a) + b := MustParseConstraint(tc.b) + a.IncludePrerelease = true + b.IncludePrerelease = true + got := Intersection(a, b).String() + if got != tc.want { + t.Errorf("Intersection(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want) + } + }) + } +} + func TestIsSubset_NilSafety(t *testing.T) { c := MustParseConstraint(">=1.2.3 <4") if IsSubset(nil, c) { @@ -212,16 +279,85 @@ func TestIsSubset(t *testing.T) { {"0.x", ">=0.1.0 <0.5.0", false}, {"~2", ">=1 <=2", true}, - // issue 21 - {"1.0.6-1", ">=1.0.3-0 <1.0.6", false}, - {"1.0.6-1", ">=1.0.3-0 <1.0.7", false}, - {"1.0.6-1", ">=1.0.3-0 <=1.0.6", false}, + {"1.0.6-1", ">=1.0.3-0 <1.0.6", true}, + {"1.0.6-1", ">=1.0.3-0 <1.0.7", true}, + {"1.0.6-1", ">=1.0.3-0 <=1.0.6", true}, + } + + for i, tc := range cases { + t.Run(fmt.Sprint("WithoutIncludePrerelease ", strconv.Itoa(i)), + func(t *testing.T) { + got := IsSubset(MustParseConstraint(tc.a), MustParseConstraint(tc.b)) + if got != tc.want { + t.Errorf("IsSubset(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + + t.Run(fmt.Sprint("IncludePrerelease ", strconv.Itoa(i)), func(t *testing.T) { + a := MustParseConstraint(tc.a) + b := MustParseConstraint(tc.b) + a.IncludePrerelease = true + b.IncludePrerelease = true + got := IsSubset(a, b) + if got != tc.want { + t.Errorf("IsSubset(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + + } +} + +func TestIsSubsetWithoutIncludePrerelease(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"4.1.0-beta", ">=1.1", false}, + {"4.1.0-beta", ">1.1", false}, + {"0.1.0-alpha", "<=1.1", false}, + {"0.1.0-alpha", "<1.1", false}, + {"1.1.1-beta1", "^1.x", false}, + {"1.1.1-alpha", "~1.1", false}, + {"1.2.3-alpha", "*", false}, + {"2.0.1-beta", "= 2.0", false}, + } + + for i, tc := range cases { + t.Run(strconv.Itoa(i), + func(t *testing.T) { + got := IsSubset(MustParseConstraint(tc.a), MustParseConstraint(tc.b)) + if got != tc.want { + t.Errorf("IsSubset(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + + } +} + +func TestIsSubsetIncludePrerelease(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"4.1.0-beta", ">=1.1", true}, + {"4.1.0-beta", ">1.1", true}, + {"0.1.0-alpha", "<=1.1", true}, + {"0.1.0-alpha", "<1.1", true}, + {"1.1.1-beta1", "^1.x", true}, + {"1.1.1-alpha", "~1.1", true}, + {"1.2.3-alpha", "*", true}, + {"2.0.1-beta", "= 2.0", true}, } for i, tc := range cases { t.Run(strconv.Itoa(i), func(t *testing.T) { - if got := IsSubset(MustParseConstraint(tc.a), MustParseConstraint(tc.b)); got != tc.want { + a := MustParseConstraint(tc.a) + b := MustParseConstraint(tc.b) + a.IncludePrerelease = true + b.IncludePrerelease = true + got := IsSubset(a, b) + if got != tc.want { t.Errorf("IsSubset(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) } })