Skip to content

Commit de1ba63

Browse files
committed
Add ecosystem-specific version handling
1 parent 84e38e9 commit de1ba63

13 files changed

Lines changed: 1606 additions & 84 deletions

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ r, _ = vers.ParseNative(">> 1.0", "deb")
6666

6767
// RPM
6868
r, _ = vers.ParseNative(">= 1.0", "rpm")
69+
70+
// Conan, OpenSSL, and Nginx
71+
r, _ = vers.ParseNative("^1.2", "conan")
72+
r, _ = vers.ParseNative("1.1.1w, 3.0.0", "openssl")
73+
r, _ = vers.ParseNative("0.8.40+", "nginx")
6974
```
7075

7176
### Check Version Satisfaction
@@ -89,6 +94,10 @@ vers.Compare("1.0.0", "1.0.0") // 0 (equal)
8994

9095
// Prerelease versions sort before stable
9196
vers.Compare("1.0.0", "1.0.0-alpha") // 1 (stable > prerelease)
97+
98+
// Use ecosystem-specific ordering where versions are not SemVer
99+
vers.CompareWithScheme("1.0~rc1", "1.0", "deb") // -1
100+
vers.CompareWithScheme("1.0.beta1", "1.0", "gem") // -1
92101
```
93102

94103
### Version Validation and Normalization
@@ -100,6 +109,9 @@ vers.Valid("invalid") // false
100109
v, _ := vers.Normalize("1") // "1.0.0"
101110
v, _ = vers.Normalize("1.2") // "1.2.0"
102111
v, _ = vers.Normalize("1.2.3") // "1.2.3"
112+
113+
vers.ValidWithScheme("1:2.3.4-1", "deb") // true
114+
v, _ = vers.NormalizeWithScheme("01!02.0RC1", "pypi") // "1!2.0rc1"
103115
```
104116

105117
### Create Ranges Programmatically
@@ -150,8 +162,15 @@ uri = vers.ToVersString(r, "npm")
150162
| NuGet | `nuget` | Same as Maven |
151163
| Cargo | `cargo` | Same as npm |
152164
| Go | `go`, `golang` | `>=1.0.0,<2.0.0` |
165+
| Hex | `hex`, `elixir` | `~> 1.2`, `>= 1.0 and < 2.0` |
153166
| Debian | `deb`, `debian` | `>> 1.0`, `<< 2.0`, `>= 1.0` |
154167
| RPM | `rpm` | `>= 1.0`, `<= 2.0` |
168+
| Conan | `conan` | `^1.2`, `~1.2`, `>1 <2`, `||` |
169+
| OpenSSL | `openssl` | `1.1.1w`, `1.1.1w, 3.0.0` |
170+
| Nginx | `nginx` | `0.8.40+`, `0.7.52-0.8.39` |
171+
172+
Version comparison additionally supports `semver`, `apk`/`alpine`, `alpm`,
173+
`gentoo`, `intdot`, `lexicographic`, and `datetime`.
155174

156175
## Development
157176

conformance_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type versionCmpInput struct {
3434
Versions []string `json:"versions"`
3535
}
3636

37+
type roundtripInput struct {
38+
Vers string `json:"vers"`
39+
}
40+
3741
func loadTestFile(t *testing.T, filename string) *versTestFile {
3842
t.Helper()
3943
path := filepath.Join("testdata", "vers-spec", "tests", filename)
@@ -50,8 +54,12 @@ func loadTestFile(t *testing.T, filename string) *versTestFile {
5054

5155
func TestConformance_FromNative(t *testing.T) {
5256
files := []string{
57+
"conan_range_from_native_basic_test.json",
58+
"conan_range_from_native_test.json",
5359
"gem_range_from_native_test.json",
60+
"nginx_range_from_native_test.json",
5461
"npm_range_from_native_test.json",
62+
"openssl_range_from_native_test.json",
5563
"pypi_range_from_native_test.json",
5664
"nuget_range_from_native_test.json",
5765
}
@@ -136,11 +144,45 @@ func TestConformance_Containment(t *testing.T) {
136144
}
137145
}
138146

147+
func TestConformance_RoundTrip(t *testing.T) {
148+
tf := loadTestFile(t, "pypi_range_roundtrip_test.json")
149+
for _, tc := range tf.Tests {
150+
if tc.TestType != "roundtrip" {
151+
continue
152+
}
153+
var input roundtripInput
154+
if err := json.Unmarshal(tc.Input, &input); err != nil {
155+
t.Errorf("failed to parse input: %v", err)
156+
continue
157+
}
158+
var expected string
159+
if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil {
160+
t.Errorf("failed to parse expected output: %v", err)
161+
continue
162+
}
163+
t.Run(input.Vers, func(t *testing.T) {
164+
r, err := Parse(input.Vers)
165+
if err != nil {
166+
t.Fatalf("Parse(%q) error: %v", input.Vers, err)
167+
}
168+
if got := ToVersString(r, r.Scheme); got != expected {
169+
t.Errorf("round trip = %q, want %q", got, expected)
170+
}
171+
})
172+
}
173+
}
174+
139175
//nolint:gocognit
140176
func TestConformance_VersionComparison(t *testing.T) {
141177
files := []string{
178+
"alpine_version_cmp_test.json",
179+
"alpm_version_cmp_test.json",
180+
"conan_version_cmp_test.json",
181+
"gentoo_version_cmp_test.json",
182+
"lexicographic-test.json",
142183
"nuget_version_cmp_test.json",
143184
"maven_version_cmp_test.json",
185+
"openssl_version_cmp_test.json",
144186
}
145187

146188
for _, file := range files {

constraint.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@ var operatorRegex = regexp.MustCompile(`^(!=|>=|<=|[<>=])`)
1616
type Constraint struct {
1717
Operator string
1818
Version string
19+
Scheme string
1920
}
2021

2122
// ParseConstraint parses a constraint string into a Constraint.
2223
func ParseConstraint(s string) (*Constraint, error) {
2324
return parseConstraintWithScheme(s, "")
2425
}
2526

27+
// ParseConstraintWithScheme parses a constraint using scheme-specific comparison rules.
28+
func ParseConstraintWithScheme(s, scheme string) (*Constraint, error) {
29+
return parseConstraintWithScheme(s, scheme)
30+
}
31+
2632
// parseConstraintWithScheme parses a constraint with scheme-specific handling.
2733
// For Go/golang schemes, the v prefix is preserved.
2834
func parseConstraintWithScheme(s, scheme string) (*Constraint, error) {
@@ -47,7 +53,7 @@ func parseConstraintWithScheme(s, scheme string) (*Constraint, error) {
4753
if !preserveVPrefix {
4854
version = stripVPrefix(version)
4955
}
50-
return &Constraint{Operator: operator, Version: version}, nil
56+
return &Constraint{Operator: operator, Version: version, Scheme: scheme}, nil
5157
}
5258

5359
// No operator found, treat as exact match
@@ -56,9 +62,9 @@ func parseConstraintWithScheme(s, scheme string) (*Constraint, error) {
5662
version = decoded
5763
}
5864
if !preserveVPrefix {
59-
version = stripVPrefix(s)
65+
version = stripVPrefix(version)
6066
}
61-
return &Constraint{Operator: "=", Version: version}, nil
67+
return &Constraint{Operator: "=", Version: version, Scheme: scheme}, nil
6268
}
6369

6470
// stripVPrefix removes a leading 'v' or 'V' from version strings.
@@ -98,7 +104,16 @@ func (c *Constraint) IsExclusion() bool {
98104

99105
// Satisfies checks if a version satisfies this constraint.
100106
func (c *Constraint) Satisfies(version string) bool {
101-
cmp := CompareVersions(version, c.Version)
107+
return c.satisfiesCmp(version, compareFuncFor(c.Scheme))
108+
}
109+
110+
// SatisfiesWithScheme checks the constraint using the specified version scheme.
111+
func (c *Constraint) SatisfiesWithScheme(version, scheme string) bool {
112+
return c.satisfiesCmp(version, compareFuncFor(scheme))
113+
}
114+
115+
func (c *Constraint) satisfiesCmp(version string, compare func(a, b string) int) bool {
116+
cmp := compare(version, c.Version)
102117

103118
switch c.Operator {
104119
case "=":

constraint_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func TestParseConstraint(t *testing.T) {
1616
{"=1.0.0", "=", "1.0.0", false},
1717
{"!=1.5.0", "!=", "1.5.0", false},
1818
{"1.0.0", "=", "1.0.0", false}, // No operator defaults to =
19+
{"1.0%2Bmeta", "=", "1.0+meta", false},
1920
{"", "", "", true},
2021
{">=", "", "", true}, // Missing version
2122
}

0 commit comments

Comments
 (0)